├── gae ├── __test__ │ ├── __init__.py │ ├── fix_sys_path.py │ ├── conftest.py │ ├── test_api.py │ ├── test_models.py │ ├── test_utils.py │ └── test_app.py ├── app │ ├── models │ │ ├── __init__.py │ │ └── name.py │ ├── utils │ │ ├── __init__.py │ │ ├── func.py │ │ ├── werkzeug_debugger.py │ │ ├── reqparse.py │ │ └── decorator.py │ ├── api │ │ ├── v1 │ │ │ ├── resources │ │ │ │ ├── __init__.py │ │ │ │ └── names.py │ │ │ └── __init__.py │ │ └── __init__.py │ └── __init__.py ├── config │ ├── production.py │ ├── config.py │ ├── test.py │ ├── __init__.py │ └── development.py ├── main.py ├── appengine_config.py └── app.yaml ├── browser_client ├── config │ ├── webpack.build.js │ ├── webpack.dev.js │ ├── webpack.test.js │ ├── webpack.coverage.js │ ├── karma.unit.js │ ├── karma.all.js │ ├── karma.e2e.js │ ├── karma.coverage.js │ ├── karma.make.js │ └── webpack.make.js ├── src │ ├── app │ │ ├── utils │ │ │ ├── Routes.js │ │ │ ├── array.js │ │ │ ├── time.js │ │ │ ├── dom.js │ │ │ ├── devLogFunctionCalls.js │ │ │ ├── deepFreeze.js │ │ │ ├── images.js │ │ │ ├── lodash.js │ │ │ ├── devLogSlowReducers.js │ │ │ ├── devFetchDebug.js │ │ │ ├── devAjaxDebug.js │ │ │ └── styles.js │ │ ├── components │ │ │ ├── index.js │ │ │ ├── Flex │ │ │ │ ├── index.js │ │ │ │ └── Flex.js │ │ │ ├── Greeting.js │ │ │ ├── GreetingControls.js │ │ │ ├── Navigation.js │ │ │ ├── MordorPage.js │ │ │ ├── Greetings.js │ │ │ ├── ShirePage.js │ │ │ ├── HomePage.js │ │ │ ├── AddNameForm.js │ │ │ └── Layout.js │ │ ├── containers │ │ │ ├── LayoutApp.js │ │ │ ├── HomeApp.js │ │ │ ├── ShireApp.js │ │ │ ├── MordorApp.js │ │ │ ├── WindowResizeListener.js │ │ │ ├── CriticalErrorAlert.js │ │ │ └── index.js │ │ ├── reducers │ │ │ ├── index.js │ │ │ └── reducers.js │ │ ├── actions │ │ │ ├── index.js │ │ │ ├── ActionTypes.js │ │ │ └── ActionCreators.js │ │ ├── store │ │ │ └── index.js │ │ └── middleware │ │ │ ├── api.js │ │ │ └── fetch.js │ └── index.js ├── __test__ │ ├── index.e2e.js │ ├── index.unit.js │ ├── index.all.js │ ├── setup.js │ ├── utils.js │ ├── components │ │ ├── GreetingControls-test.js │ │ └── AddNameForm-test.js │ ├── utils │ │ └── deepFreeze-test.js │ ├── e2e │ │ └── greetings-e2e.js │ └── middleware │ │ ├── api-test.js │ │ └── fetch-test.js └── html │ ├── index.prod.html │ └── index.dev.html ├── .eslintignore ├── Procfile ├── requirements.app.txt ├── setup.cfg ├── requirements.dev.txt ├── Makefile ├── LICENSE ├── .gitignore ├── .eslintrc ├── package.json └── README.md /gae/__test__/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /gae/app/models/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /gae/app/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /gae/app/api/v1/resources/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /browser_client/config/webpack.build.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./webpack.make')({}) 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | **/.cache/* 2 | **/__coverage_reports__/* 3 | **/gae/* 4 | **/node_modules/* 5 | -------------------------------------------------------------------------------- /browser_client/config/webpack.dev.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./webpack.make')({ 2 | __DEV__: true, 3 | }) 4 | -------------------------------------------------------------------------------- /browser_client/config/webpack.test.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./webpack.make')({ 2 | __TEST__: true, 3 | }) 4 | -------------------------------------------------------------------------------- /gae/config/production.py: -------------------------------------------------------------------------------- 1 | from .config import Config 2 | 3 | 4 | class Production(Config): 5 | FLASK_CONFIG = 'production' 6 | -------------------------------------------------------------------------------- /browser_client/src/app/utils/Routes.js: -------------------------------------------------------------------------------- 1 | export default { 2 | HOME: '/', 3 | SHIRE: '/shire', 4 | MORDOR: '/mordor', 5 | } 6 | -------------------------------------------------------------------------------- /browser_client/config/webpack.coverage.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./webpack.make')({ 2 | __TEST__: true, 3 | __COVERAGE__: true, 4 | }) 5 | -------------------------------------------------------------------------------- /browser_client/src/app/utils/array.js: -------------------------------------------------------------------------------- 1 | export function randomElement (array) { 2 | return array[ Math.floor( Math.random() * array.length ) ] 3 | } 4 | -------------------------------------------------------------------------------- /gae/app/api/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | 4 | api_blueprint = Blueprint('api', __name__) 5 | 6 | 7 | from . import v1 # pragma: no flakes 8 | -------------------------------------------------------------------------------- /browser_client/__test__/index.e2e.js: -------------------------------------------------------------------------------- 1 | import './setup' 2 | 3 | 4 | const e2eTests = require.context('./e2e', true, /-e2e\.js$/) 5 | e2eTests.keys().forEach(e2eTests) 6 | -------------------------------------------------------------------------------- /browser_client/__test__/index.unit.js: -------------------------------------------------------------------------------- 1 | import './setup' 2 | 3 | 4 | const unitTests = require.context('.', true, /-test\.js$/) 5 | unitTests.keys().forEach(unitTests) 6 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | gae: dev_appserver.py gae/ --show_mail_body True --allow_skipped_files True 2 | webpack: npm --silent start 3 | karma: npm run --silent test:watch 4 | pytest: py.test --looponfail 5 | -------------------------------------------------------------------------------- /browser_client/config/karma.unit.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-var */ 2 | 3 | 4 | module.exports = function (config) { 5 | var karmaSettings = require('./karma.make')({}) 6 | config.set(karmaSettings) 7 | } 8 | -------------------------------------------------------------------------------- /gae/app/utils/func.py: -------------------------------------------------------------------------------- 1 | def compose(*functions): 2 | "e.g., compose(f1, f2, f3)(x) == f1(f2(f3(x)))" 3 | reducer = lambda value, fn: fn(value) 4 | return lambda arg: reduce(reducer, reversed(functions), arg) 5 | -------------------------------------------------------------------------------- /gae/config/config.py: -------------------------------------------------------------------------------- 1 | class Config: 2 | BUNDLE_ERRORS = True 3 | # see: http://flask-restful-cn.readthedocs.org/en/0.3.4/reqparse.html#error-handling 4 | 5 | @classmethod 6 | def init_app(cls, app): 7 | pass 8 | -------------------------------------------------------------------------------- /requirements.app.txt: -------------------------------------------------------------------------------- 1 | aniso8601==1.1.0 2 | Flask==0.10.1 3 | Flask-RESTful==0.3.4 4 | itsdangerous==0.24 5 | Jinja2==2.8 6 | MarkupSafe==0.23 7 | python-dateutil==2.4.2 8 | pytz==2015.7 9 | six==1.10.0 10 | Werkzeug==0.10.4 11 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [pytest] 2 | testpaths = gae/__test__ 3 | looponfailroots = gae/__test__ gae/app gae/config 4 | 5 | [coverage:run] 6 | branch = True 7 | 8 | [coverage:html] 9 | directory = __coverage_reports__/gae_app 10 | title = GAE Flask App 11 | -------------------------------------------------------------------------------- /browser_client/config/karma.all.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-var */ 2 | 3 | 4 | module.exports = function (config) { 5 | var karmaSettings = require('./karma.make')({ 6 | __ALL__: true, 7 | }) 8 | 9 | config.set(karmaSettings) 10 | } 11 | -------------------------------------------------------------------------------- /browser_client/config/karma.e2e.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-var */ 2 | 3 | 4 | module.exports = function (config) { 5 | var karmaSettings = require('./karma.make')({ 6 | __E2E__: true, 7 | }) 8 | 9 | config.set(karmaSettings) 10 | } 11 | -------------------------------------------------------------------------------- /browser_client/src/app/utils/time.js: -------------------------------------------------------------------------------- 1 | // why? minimize reliance on sinon and its fake timers for testing 2 | export function wrappedSetTimeout (fn, delay) { 3 | if (__TEST__) { 4 | return fn() 5 | } 6 | 7 | return setTimeout(fn, delay) 8 | } 9 | -------------------------------------------------------------------------------- /browser_client/__test__/index.all.js: -------------------------------------------------------------------------------- 1 | import './setup' 2 | 3 | 4 | const unitTests = require.context('.', true, /-test\.js$/) 5 | unitTests.keys().forEach(unitTests) 6 | 7 | 8 | const e2eTests = require.context('./e2e', true, /-e2e\.js$/) 9 | e2eTests.keys().forEach(e2eTests) 10 | -------------------------------------------------------------------------------- /browser_client/config/karma.coverage.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-var */ 2 | 3 | 4 | module.exports = function (config) { 5 | var karmaSettings = require('./karma.make')({ 6 | __COVERAGE__: true, 7 | __ALL__: true, 8 | }) 9 | 10 | config.set(karmaSettings) 11 | } 12 | -------------------------------------------------------------------------------- /browser_client/src/app/components/index.js: -------------------------------------------------------------------------------- 1 | import Layout from './Layout' 2 | import HomePage from './HomePage' 3 | import MordorPage from './MordorPage' 4 | import ShirePage from './ShirePage' 5 | 6 | 7 | export default { 8 | Layout, 9 | HomePage, 10 | MordorPage, 11 | ShirePage, 12 | } 13 | -------------------------------------------------------------------------------- /browser_client/src/app/containers/LayoutApp.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux' 2 | import { Layout } from 'app/components' 3 | 4 | 5 | const mapStateToProps = ({ windowSize }) => ({ windowSize }) 6 | const createApp = () => connect(mapStateToProps)(Layout) 7 | 8 | 9 | export default createApp() 10 | -------------------------------------------------------------------------------- /browser_client/src/app/components/Flex/index.js: -------------------------------------------------------------------------------- 1 | import * as FlexLayout from './Flex' 2 | import pure from 'recompose/pure' 3 | 4 | 5 | const PureFlexLayout = Object.keys(FlexLayout).reduce( (obj, key) => { 6 | obj[key] = pure(FlexLayout[key]) 7 | return obj 8 | }, {}) 9 | 10 | 11 | export default PureFlexLayout 12 | -------------------------------------------------------------------------------- /browser_client/src/app/reducers/index.js: -------------------------------------------------------------------------------- 1 | import * as _Reducers from './reducers' 2 | 3 | 4 | let Reducers 5 | if (__TEST__) { 6 | const { deepFreezeFunctions } = require('app/utils/deepFreeze') 7 | Reducers = deepFreezeFunctions(_Reducers) 8 | } else { 9 | Reducers = _Reducers 10 | } 11 | 12 | 13 | export default Reducers 14 | -------------------------------------------------------------------------------- /gae/config/test.py: -------------------------------------------------------------------------------- 1 | from .config import Config 2 | 3 | 4 | class Test(Config): 5 | FLASK_CONFIG = 'test' 6 | TESTING = True 7 | # Disables error catching during request handling, 8 | # resulting in better error reports when performing 9 | # test requests against the application. 10 | # See: http://flask.pocoo.org/docs/0.10/testing/#the-testing-skeleton 11 | -------------------------------------------------------------------------------- /gae/config/__init__.py: -------------------------------------------------------------------------------- 1 | from .production import Production 2 | 3 | try: 4 | from .test import Test 5 | except ImportError: 6 | Test = Production 7 | 8 | try: 9 | from .development import Development 10 | except ImportError: 11 | Development = Production 12 | 13 | 14 | config = { 15 | 'test': Test, 16 | 'production': Production, 17 | 'development': Development 18 | } 19 | -------------------------------------------------------------------------------- /browser_client/src/app/utils/dom.js: -------------------------------------------------------------------------------- 1 | import devLogFunctionCalls from './devLogFunctionCalls' 2 | 3 | 4 | // thanks to https://github.com/cesarandreu/react-window-resize-listener 5 | const _getWindowWidth = () => 6 | window.innerWidth || 7 | document.documentElement.clientWidth || 8 | document.body.clientWidth 9 | 10 | 11 | export const getWindowWidth = devLogFunctionCalls(_getWindowWidth) 12 | -------------------------------------------------------------------------------- /browser_client/src/app/utils/devLogFunctionCalls.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | 4 | export default function devLogFunctionCalls (fn) { 5 | if (__DEV__) { 6 | return (...args) => { 7 | const returnValue = fn(...args) 8 | console.log(`${fn.name}(${args.join(', ')}) ->`, returnValue) 9 | return returnValue 10 | } 11 | } 12 | 13 | return fn 14 | } 15 | -------------------------------------------------------------------------------- /gae/app/__init__.py: -------------------------------------------------------------------------------- 1 | def create_app(config): 2 | from flask import Flask 3 | app = Flask(__name__) 4 | app.config.from_object(config) 5 | 6 | config.init_app(app) 7 | 8 | from .api import api_blueprint 9 | app.register_blueprint(api_blueprint, url_prefix='/api') 10 | 11 | 12 | @app.route('/_ah/warmup') 13 | def warmup(): 14 | return 'Warmed up!' 15 | 16 | 17 | return app 18 | -------------------------------------------------------------------------------- /requirements.dev.txt: -------------------------------------------------------------------------------- 1 | apipkg==1.4 2 | blessings==1.6 3 | bpython==0.14.2 4 | coverage==4.0.3 5 | curtsies==0.1.19 6 | execnet==1.4.1 7 | greenlet==0.4.9 8 | honcho==0.6.6 9 | pep8==1.6.2 10 | pipdeptree==0.5.0 11 | py==1.4.31 12 | pyflakes==1.0.0 13 | Pygments==2.0.2 14 | pytest==2.8.5 15 | pytest-cache==1.0 16 | pytest-cov==2.2.0 17 | pytest-flakes==1.0.1 18 | pytest-pep8==1.0.6 19 | pytest-xdist==1.13.1 20 | requests==2.7.0 21 | six==1.9.0 22 | wheel==0.24.0 23 | -------------------------------------------------------------------------------- /browser_client/src/app/actions/index.js: -------------------------------------------------------------------------------- 1 | import ActionTypes from './ActionTypes' 2 | import * as _ActionCreators from './ActionCreators' 3 | 4 | 5 | let ActionCreators 6 | if (__TEST__) { 7 | const { deepFreezeFunctions } = require('app/utils/deepFreeze') 8 | ActionCreators = deepFreezeFunctions(_ActionCreators) 9 | } else { 10 | ActionCreators = _ActionCreators 11 | } 12 | 13 | 14 | export default { 15 | ActionTypes, 16 | ActionCreators, 17 | } 18 | -------------------------------------------------------------------------------- /browser_client/__test__/setup.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable prefer-arrow-callback */ 2 | 3 | 4 | import { VelocityTransitionGroup } from 'velocity-react' 5 | import sinonChai from 'sinon-chai' 6 | import chaiEnzyme from 'chai-enzyme' 7 | import injectTapEventPlugin from 'react-tap-event-plugin' 8 | 9 | 10 | before(function () { 11 | VelocityTransitionGroup.disabledForTest = true 12 | chai.use(sinonChai) 13 | chai.use(chaiEnzyme()) 14 | injectTapEventPlugin() 15 | }) 16 | -------------------------------------------------------------------------------- /gae/app/utils/werkzeug_debugger.py: -------------------------------------------------------------------------------- 1 | """ 2 | adapted from: http://flask.pocoo.org/snippets/21/ 3 | """ 4 | 5 | 6 | from flask import current_app 7 | 8 | 9 | def werkzeug_debugger(): 10 | """ 11 | Call this where you'd like to force the Werkzeug debugger to open. 12 | Assertion error will only be thrown when the app is in debug mode, 13 | protecting you if you accidentally leave a `werkzeug_debugger()` 14 | call inline when deploying. 15 | """ 16 | assert current_app.debug is not True, 'werkzeug_debugger()' 17 | -------------------------------------------------------------------------------- /browser_client/src/app/utils/deepFreeze.js: -------------------------------------------------------------------------------- 1 | import _deepFreeze from 'deep-freeze-node' 2 | 3 | 4 | export const deepFreeze = _deepFreeze 5 | 6 | 7 | export const deepFreezeFunction = fn => (...args) => 8 | deepFreeze( fn( ...deepFreeze(args) ) ) 9 | 10 | 11 | export const deepFreezeFunctions = fns => 12 | Object.keys(fns).reduce( (frozenFns, key) => { 13 | const obj = fns[key] 14 | 15 | frozenFns[key] = typeof obj === 'function' 16 | ? deepFreezeFunction(obj) 17 | : obj 18 | 19 | return frozenFns 20 | }, {}) 21 | -------------------------------------------------------------------------------- /browser_client/__test__/utils.js: -------------------------------------------------------------------------------- 1 | import { 2 | scryRenderedDOMComponentsWithTag, 3 | scryRenderedDOMComponentsWithClass, 4 | scryRenderedComponentsWithType, 5 | } from 'react-addons-test-utils' 6 | 7 | 8 | export const createFinder = reactTree => query => { 9 | if (typeof query !== 'string') { 10 | return scryRenderedComponentsWithType(reactTree, query) 11 | } else if (query.startsWith('.')) { 12 | return scryRenderedDOMComponentsWithClass(reactTree, query.substring(1)) 13 | } else { 14 | return scryRenderedDOMComponentsWithTag(reactTree, query) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /gae/main.py: -------------------------------------------------------------------------------- 1 | """ 2 | `main` is the top level module where AppEngine gets access 3 | to your Flask application. 4 | """ 5 | 6 | 7 | from app import create_app 8 | from config import config 9 | from os import environ 10 | 11 | 12 | server_software_name = environ['SERVER_SOFTWARE'] 13 | 14 | 15 | if server_software_name.startswith('Development'): 16 | app_config = config['development'] 17 | else: 18 | app_config = config['production'] 19 | 20 | 21 | app = create_app(app_config) 22 | # Note: We don't need to call run() since our application is 23 | # embedded within the App Engine WSGI application server. 24 | -------------------------------------------------------------------------------- /gae/appengine_config.py: -------------------------------------------------------------------------------- 1 | """ 2 | `appengine_config.py` is automatically loaded when Google App Engine 3 | starts a new instance of your application. This runs before any 4 | WSGI applications specified in app.yaml are loaded. 5 | see: https://github.com/GoogleCloudPlatform/appengine-flask-skeleton 6 | """ 7 | 8 | 9 | from google.appengine.ext import vendor 10 | from os import path 11 | 12 | 13 | # Third-party libraries are stored in "__app_env__", vendoring will make 14 | # sure that they are importable by the application. 15 | app_rootdir = path.dirname(__file__) 16 | vendor.add(path.join(app_rootdir, '__app_env__')) 17 | -------------------------------------------------------------------------------- /browser_client/src/app/actions/ActionTypes.js: -------------------------------------------------------------------------------- 1 | export default { 2 | WINDOW_DATA: 'WINDOW_DATA', 3 | SUBTRACT_LAST_NAME: 'SUBTRACT_LAST_NAME', 4 | ADD_NAME: 'ADD_NAME', 5 | ADD_NAME_DONE: 'ADD_NAME_DONE', 6 | ADD_NAME_FAIL: 'ADD_NAME_FAIL', 7 | SERVER_ERROR: 'SERVER_ERROR', 8 | CLEAR_SERVER_ERROR: 'CLEAR_SERVER_ERROR', 9 | NETWORK_ERROR: 'NETWORK_ERROR', 10 | CLEAR_NETWORK_ERROR: 'CLEAR_NETWORK_ERROR', 11 | CLEAR_SERVER_VALIDATION: 'CLEAR_SERVER_VALIDATION', 12 | ENTERED_PAGE_PATH: 'ENTERED_PAGE_PATH', 13 | } 14 | -------------------------------------------------------------------------------- /browser_client/src/app/components/Greeting.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react' 2 | import Component from 'react-pure-render/component' 3 | 4 | 5 | export default class Greeting extends Component { 6 | render () { 7 | const { salutation, name } = this.props 8 | 9 | return ( 10 |
11 | {`${salutation}, ${name.name}!`} 12 |
13 | ) 14 | } 15 | } 16 | 17 | 18 | Greeting.propTypes = { 19 | salutation: PropTypes.string.isRequired, 20 | name: PropTypes.shape({ 21 | name: PropTypes.string.isRequired, 22 | }).isRequired, 23 | } 24 | -------------------------------------------------------------------------------- /browser_client/src/app/containers/HomeApp.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux' 2 | import { HomePage } from 'app/components' 3 | import { ActionCreators } from 'app/actions' 4 | 5 | 6 | const { addName, clearServerValidation } = ActionCreators 7 | 8 | 9 | const mapStateToProps = ({ 10 | names, 11 | serverValidation, 12 | pendingRequests, 13 | }) => ({ 14 | names, 15 | serverValidation, 16 | requestsPending: pendingRequests.size > 0, 17 | }) 18 | 19 | 20 | const createApp = () => connect(mapStateToProps, { 21 | addName, clearServerValidation 22 | })(HomePage) 23 | 24 | 25 | export default createApp() 26 | -------------------------------------------------------------------------------- /gae/__test__/fix_sys_path.py: -------------------------------------------------------------------------------- 1 | """ 2 | GAE path setup borrowed from: 3 | https://cloud.google.com/appengine/docs/python/tools 4 | /localunittesting#Python_Setting_up_a_testing_framework 5 | 6 | After this script runs, all `google.appengine.*` packages 7 | are available for import, as well as all GAE-bundled third-party 8 | packages. 9 | """ 10 | 11 | 12 | import os 13 | import sys 14 | 15 | 16 | gae_sdk_path = os.path.expanduser('~/google-cloud-sdk/platform/google_appengine') 17 | 18 | 19 | if gae_sdk_path not in sys.path: 20 | sys.path.insert(0, gae_sdk_path) 21 | 22 | 23 | import dev_appserver 24 | 25 | 26 | dev_appserver.fix_sys_path() 27 | -------------------------------------------------------------------------------- /browser_client/src/app/containers/ShireApp.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux' 2 | import { ShirePage } from 'app/components' 3 | import { ActionCreators } from 'app/actions' 4 | 5 | 6 | // Construct a 'smart container' around a 'dumb' component. 7 | // The construction for ShirePage and MordorPage are the same. 8 | // However, they could differ and probably would in any 9 | // real-world app. 10 | 11 | 12 | const mapStateToProps = ({ names, pendingRequests }) => 13 | ({ names, requestsPending: pendingRequests.size > 0 }) 14 | 15 | 16 | const createApp = () => connect(mapStateToProps, ActionCreators)(ShirePage) 17 | 18 | 19 | export default createApp() 20 | -------------------------------------------------------------------------------- /browser_client/src/app/containers/MordorApp.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux' 2 | import { MordorPage } from 'app/components' 3 | import { ActionCreators } from 'app/actions' 4 | 5 | 6 | // Construct a 'smart container' around a 'dumb' component. 7 | // The construction for ShirePage and MordorPage are the same. 8 | // However, they could differ and probably would in any 9 | // real-world app. 10 | 11 | 12 | const mapStateToProps = ({ names, pendingRequests }) => 13 | ({ names, requestsPending: pendingRequests.size > 0 }) 14 | 15 | 16 | const createApp = () => connect(mapStateToProps, ActionCreators)(MordorPage) 17 | 18 | 19 | export default createApp() 20 | -------------------------------------------------------------------------------- /gae/app/utils/reqparse.py: -------------------------------------------------------------------------------- 1 | def string_length(min_len=None, max_len=None): 2 | plural_min = '' if min_len == 1 else 's' 3 | plural_max = '' if max_len == 1 else 's' 4 | 5 | min_message = 'must be at least {} character{} long' 6 | max_message = 'must be no more than {} character{} long' 7 | 8 | def _f(string): 9 | string_len = len(string) 10 | 11 | if min_len is not None and string_len < min_len: 12 | raise ValueError(min_message.format(min_len, plural_min)) 13 | elif max_len is not None and max_len < string_len: 14 | raise ValueError(max_message.format(max_len, plural_max)) 15 | 16 | return string 17 | 18 | return _f 19 | -------------------------------------------------------------------------------- /gae/app/models/name.py: -------------------------------------------------------------------------------- 1 | from google.appengine.ext import ndb 2 | import random 3 | 4 | 5 | root = ndb.Key('NameRoot', 'name_root') 6 | 7 | 8 | class Name(ndb.Model): 9 | name = ndb.StringProperty(required=True, indexed=True) 10 | created = ndb.DateTimeProperty(required=True, auto_now_add=True) 11 | 12 | @classmethod 13 | def ensure_name_not_in_datastore(cls, value): 14 | if cls.query(cls.name == value, ancestor=root).count() > 0: 15 | raise ValueError('"{}" already exists'.format(value)) 16 | 17 | return value 18 | 19 | @classmethod 20 | def random_key(cls, excluding=None): 21 | limit = 10 22 | keys = cls.query(ancestor=root).fetch(limit, keys_only=True) 23 | 24 | if excluding: 25 | keys = [k for k in keys if k != excluding] 26 | 27 | return random.choice(keys) 28 | -------------------------------------------------------------------------------- /browser_client/src/index.js: -------------------------------------------------------------------------------- 1 | import 'velocity-react' 2 | // activate Velocity context, and... 3 | import 'velocity-animate/velocity.ui' 4 | // ...make Velocity UI Pack available to Velocity and velocity-react 5 | 6 | 7 | import React from 'react' 8 | import ReactDOM from 'react-dom' 9 | import createBrowserHistory from 'history/lib/createBrowserHistory' 10 | import injectTapEventPlugin from 'react-tap-event-plugin' 11 | import Root from 'app/containers' 12 | import store from 'app/store' 13 | 14 | 15 | // Needed for onTouchTap 16 | // Can go away when react 1.0 release 17 | // Check this repo: 18 | // https://github.com/zilverline/react-tap-event-plugin 19 | injectTapEventPlugin() 20 | 21 | 22 | ReactDOM.render( 23 | /* eslint-disable new-cap */ 24 | , 25 | document.getElementById('user-interface') 26 | ) 27 | 28 | 29 | if (__DEV__) { 30 | window.Perf = require('react-addons-perf') 31 | } 32 | -------------------------------------------------------------------------------- /browser_client/src/app/utils/images.js: -------------------------------------------------------------------------------- 1 | import { WindowSizes } from './styles' 2 | import { SHIRE } from 'app/utils/Routes' 3 | 4 | 5 | const { SMALL, MEDIUM, LARGE } = WindowSizes 6 | 7 | 8 | const baseImageUrl = 'https://upload.wikimedia.org/wikipedia/commons/thumb/' 9 | 10 | 11 | const ImageUrls = { 12 | [SHIRE]: { 13 | [SMALL]: `${baseImageUrl}4/4c/Hobbiton%2C_New_Zealand.jpg/800px-Hobbiton%2C_New_Zealand.jpg`, 14 | [MEDIUM]: `${baseImageUrl}4/4c/Hobbiton%2C_New_Zealand.jpg/1024px-Hobbiton%2C_New_Zealand.jpg`, 15 | [LARGE]: `${baseImageUrl}8/89/Hobbit_holes_reflected_in_water.jpg/1280px-Hobbit_holes_reflected_in_water.jpg`, 16 | }, 17 | } 18 | 19 | 20 | export const imageMarkup = (collectionKey, windowSize = SMALL) => { 21 | if (__TEST__) { 22 | return '' 23 | } 24 | 25 | const imageCollection = ImageUrls[collectionKey] 26 | return imageCollection 27 | ? `url(${imageCollection[windowSize]})` 28 | : null 29 | } 30 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | rehydrate: pip npm 2 | 3 | pip: pip-app pip-dev 4 | 5 | pip-app: 6 | pip install --requirement=requirements.app.txt --target=./gae/__app_env__ 7 | 8 | pip-dev: 9 | pip install --requirement=requirements.dev.txt 10 | 11 | npm: 12 | npm install 13 | 14 | coverage: browser-app-coverage gae-app-coverage 15 | 16 | gae-app-coverage: 17 | py.test --cov-report html --cov=gae/app --cov=gae/config 18 | open __coverage_reports__/gae_app/index.html 19 | # relies on configuration in `setup.cfg` 20 | 21 | browser-app-coverage: 22 | npm run test:coverage 23 | open __coverage_reports__/browser_client/*/index.html 24 | # relies on configuration in `package.json` and 25 | # in `browser_client/config/karma.make.js` 26 | 27 | check: 28 | py.test --flakes gae/app gae/config/ gae/__test__/ 29 | npm run check 30 | 31 | build: clean 32 | npm run build 33 | 34 | deploy: check build 35 | appcfg.py -A your-app-id-here update gae/ 36 | 37 | clean: 38 | rm -vf gae/static/main.js gae/static/index.html 39 | -------------------------------------------------------------------------------- /browser_client/src/app/utils/lodash.js: -------------------------------------------------------------------------------- 1 | import uniqueId from 'lodash/utility/uniqueId' 2 | import memoize from 'lodash/function/memoize' 3 | import isPlainObject from 'lodash/lang/isPlainObject' 4 | import debounce from 'lodash/function/debounce' 5 | 6 | 7 | const defaultExport = { 8 | uniqueId, 9 | memoize, 10 | isPlainObject, 11 | debounce, 12 | } 13 | 14 | 15 | const makeExport = () => { 16 | if (__TEST__) { 17 | // Using a copy of lodash that's run in the `window` context 18 | // allows us to make use of sinon's fake time and control functions 19 | // whose timing is regulated by certain lodash functions, such as 20 | // `debounce` and `throttle`. 21 | const lodash = require('lodash').runInContext(window) 22 | return Object.keys(defaultExport).reduce((obj, key) => 23 | ({ ...obj, [key]: lodash[key] }) 24 | , {}) 25 | } else { 26 | return defaultExport 27 | } 28 | } 29 | 30 | 31 | export default makeExport() 32 | -------------------------------------------------------------------------------- /browser_client/src/app/utils/devLogSlowReducers.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | /** 4 | adapted from: 5 | https://github.com/michaelcontento/redux-log-slow-reducers/blob/master/src/index.js 6 | returns the original reducers wrapped in a timing/logging procedure 7 | */ 8 | 9 | 10 | const logSlowReducers = (reducers, thresholdMs = 4) => 11 | Object.keys(reducers).reduce( (obj, key) => { 12 | const reducer = reducers[key] 13 | 14 | return { 15 | ...obj, 16 | 17 | [key]: (state, action) => { 18 | const t0 = Date.now() 19 | const newState = reducer(state, action) 20 | const diffMs = Date.now() - t0 21 | 22 | if (diffMs > thresholdMs) { 23 | console.warn(`Reducer \`${key}\` took ${diffMs}ms for \`${action.type}\`.`) 24 | } 25 | 26 | return newState 27 | }, 28 | 29 | } 30 | }, {}) 31 | 32 | 33 | export default logSlowReducers 34 | -------------------------------------------------------------------------------- /browser_client/src/app/utils/devFetchDebug.js: -------------------------------------------------------------------------------- 1 | /* 2 | thanks to: 3 | http://stackoverflow.com/a/27583970/1941513 4 | http://stackoverflow.com/a/3354511/1941513 5 | http://ilee.co.uk/changing-url-without-page-refresh/ 6 | http://stackoverflow.com/a/4001415/1941513 7 | http://stackoverflow.com/a/11933007/1941513 8 | http://stackoverflow.com/a/3340186/1941513 9 | */ 10 | const werkzeugDebugger = (flaskResponse, url, fetchCall) => { 11 | if (!sameOrigin(url)) return 12 | 13 | if (!confirm('__DEV__: Server Error! Open Werkzeug Debugger?')) return 14 | 15 | window.history.pushState({}, 'Werkzeug Debugger', fetchCall.endpoint) 16 | 17 | try { 18 | window.document.open() 19 | window.document.write(flaskResponse) 20 | } finally { 21 | window.document.close() 22 | } 23 | } 24 | 25 | 26 | /* 27 | thanks to: https://gist.github.com/jlong/2428561 28 | */ 29 | const sameOrigin = url => { 30 | const parser = document.createElement('a') 31 | parser.href = url 32 | return parser.origin === window.location.origin 33 | } 34 | 35 | 36 | export default werkzeugDebugger 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 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 | 23 | -------------------------------------------------------------------------------- /browser_client/html/index.prod.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Starter Kit 8 | 22 | 32 | 33 | 34 |
35 | 36 | 37 | -------------------------------------------------------------------------------- /gae/__test__/conftest.py: -------------------------------------------------------------------------------- 1 | import fix_sys_path # pragma: no flakes 2 | 3 | 4 | import pytest 5 | import os 6 | from google.appengine.ext.testbed import Testbed 7 | from google.appengine.ext import ndb 8 | 9 | 10 | # @pytest.fixture(scope='session', autouse=True) 11 | # def global_setup_and_teardown(request): 12 | # """ 13 | # use of pytest fixture borrowed from: 14 | # http://stackoverflow.com/questions/14399908 15 | # /py-test-setup-teardown-for-whole-test-suite 16 | # """ 17 | # pass 18 | 19 | 20 | @pytest.yield_fixture(scope='function') 21 | def testbed(): 22 | testbed = Testbed() 23 | testbed.activate() 24 | # testbed.setup_env(app_id='_') 25 | os.environ['APPLICATION_ID'] = '_' 26 | # this is a hack to get things working; `testbed.setup_env` does 27 | # not seem to be doing the job 28 | # see: 29 | # http://einaregilsson.com/unit-testing-model-classes-in-google-app-engine/ 30 | 31 | # will almost always need datastore for tests that use this fixture 32 | testbed.init_datastore_v3_stub() 33 | # ndb uses memcache, so stub it as well 34 | testbed.init_memcache_stub() 35 | # clear in-context memcache before test 36 | ndb.get_context().clear_cache() 37 | 38 | yield testbed 39 | 40 | ndb.get_context().clear_cache() 41 | testbed.deactivate() 42 | -------------------------------------------------------------------------------- /gae/config/development.py: -------------------------------------------------------------------------------- 1 | from .config import Config 2 | from werkzeug.debug import DebuggedApplication 3 | from werkzeug.contrib.profiler import ProfilerMiddleware 4 | from app.models.name import Name, root 5 | 6 | 7 | DEBUG = 1 8 | PROFILE = 1 9 | 10 | 11 | class Development(Config): 12 | FLASK_CONFIG = 'development' 13 | 14 | @classmethod 15 | def init_app(cls, app): 16 | Config.init_app(app) 17 | 18 | if DEBUG: 19 | app.debug = True 20 | app.wsgi_app = DebuggedApplication(app.wsgi_app, 21 | evalex=True, 22 | console_path='/_console') 23 | # In order for debug to work with GAE, use DebuggedApplication. 24 | 25 | if PROFILE: 26 | app.wsgi_app = ProfilerMiddleware(app.wsgi_app, restrictions=('gae/app', 10)) 27 | # For each HTTP request, log top ten slowest functions from the 28 | # source code in the `gae/app` directory. 29 | 30 | for droid_name in ('bb8', 'r2d2', 'c3po'): 31 | if Name.query(Name.name == droid_name).count() == 0: 32 | Name(parent=root, name=droid_name).put() 33 | # ensure minimal data for dev in datastore 34 | # no need to worry about a transaction 35 | -------------------------------------------------------------------------------- /browser_client/src/app/utils/devAjaxDebug.js: -------------------------------------------------------------------------------- 1 | import $ from 'jquery' 2 | 3 | 4 | /* eslint-disable no-console */ 5 | /* eslint-disable no-unused-vars */ 6 | 7 | 8 | export default function initializeAjaxDebuggingUtils () { 9 | if (document) { 10 | ajaxErrorDebug() 11 | } 12 | 13 | if (document && console && console.log) { 14 | ajaxCompleteLog() 15 | } 16 | } 17 | 18 | 19 | function ajaxErrorDebug () { 20 | $(document).ajaxError((event, jqXHR, settings, error) => { 21 | if (jqXHR.status >= 500 && !settings.crossDomain) { 22 | werkzeugDebugger(jqXHR.responseText, settings.url) 23 | } 24 | }) 25 | } 26 | 27 | 28 | function werkzeugDebugger (flaskResponse, path) { 29 | const debuggerLocation = window.location.origin + path 30 | const debuggerWindow = window.open(debuggerLocation, 'Werkzeug Debugger') 31 | debuggerWindow.document.open() 32 | debuggerWindow.location.href = debuggerLocation 33 | debuggerWindow.document.write(flaskResponse) 34 | debuggerWindow.document.close() 35 | } 36 | 37 | 38 | function ajaxCompleteLog () { 39 | $(document).ajaxComplete((event, jqXHR, settings) => { 40 | const data = { 41 | event: event, 42 | jqXHR: jqXHR, 43 | settings: settings 44 | } 45 | console.log('[AJAX Complete]', data) 46 | }) 47 | } 48 | -------------------------------------------------------------------------------- /browser_client/html/index.dev.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Starter Kit 8 | 22 | 35 | 36 | 37 |
38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /gae/app/utils/decorator.py: -------------------------------------------------------------------------------- 1 | """ 2 | Adapted from Peter Norvig's Design of Computer Programs 3 | (https://www.udacity.com/course/design-of-computer-programs--cs212) 4 | 5 | Example use of `decorator` 6 | 7 | from utils.decorator import decorator as d 8 | 9 | @d(app.route('/')) 10 | @jsonify 11 | @validate_form(MyForm) 12 | def index(my_form): 13 | return 'hi' 14 | 15 | Where `app.route` is defined outside the project (and therefore needs to be 16 | wrapped with standard function notation `d(...)` instead of decorator notation) 17 | and `jsonify` and `validate_form` are defined in the project -- e.g.: 18 | 19 | @decorator 20 | def jsonify(f): 21 | ... 22 | """ 23 | 24 | 25 | from functools import update_wrapper 26 | 27 | 28 | def _decorator(d): 29 | "make function `d` a decorator: `d` wraps function `f` and takes on metadata" 30 | def decorator(f): 31 | _f = d(f) # what `d` was designed to do 32 | update_wrapper(_f, f) 33 | _f.__wrapped__ = f.func_dict.get('__wrapped__', f) 34 | # the above line ensures base function is passed up entire chain of 35 | # decorators for easy retrieval for testing 36 | return _f 37 | 38 | return decorator 39 | 40 | 41 | # apply `_decorator` to itself so that it behaves like a proper decorator, 42 | # updating metadata and setting `__wrapped__` on the wrapper it returns 43 | decorator = _decorator(_decorator) 44 | -------------------------------------------------------------------------------- /browser_client/src/app/store/index.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware, combineReducers, compose } from 'redux' 2 | import apiMiddleware from 'app/middleware/api' 3 | import * as reducers from 'app/reducers' 4 | 5 | 6 | export const createStoreWithMiddleware = () => { 7 | const middleware = applyMiddleware(apiMiddleware) 8 | 9 | if (__DEV__) { 10 | const logSlowReducers = require('app/utils/devLogSlowReducers') 11 | const reducer = combineReducers(logSlowReducers(reducers)) 12 | 13 | const { devTools, persistState } = require('redux-devtools') 14 | 15 | const finalCreateStore = compose( 16 | middleware, 17 | devTools(), 18 | persistState(window.location.href.match(/[?&]debug_session=([^&]+)\b/)) 19 | )(createStore) 20 | 21 | const store = finalCreateStore(reducer) 22 | 23 | if (module.hot) { // Enable Webpack hot module replacement for reducers 24 | module.hot.accept('app/reducers', () => { 25 | const nextRootReducer = combineReducers( 26 | logSlowReducers(require('app/reducers/index')) 27 | ) 28 | store.replaceReducer(nextRootReducer) 29 | }) 30 | } 31 | 32 | return store 33 | } else { 34 | const reducer = combineReducers(reducers) 35 | return middleware(createStore)(reducer) 36 | } 37 | } 38 | 39 | 40 | export default createStoreWithMiddleware() 41 | -------------------------------------------------------------------------------- /gae/__test__/test_api.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import json 3 | from flask import Flask 4 | from app.api.v1 import output_json 5 | 6 | 7 | def test_output_json(): 8 | app = Flask(__name__) 9 | headers = { 'hi': 'there' } 10 | 11 | with pytest.raises(NotImplementedError): 12 | int_data = 1 13 | output_json(int_data, 200, headers) 14 | 15 | with app.app_context(): 16 | with app.test_request_context('/'): 17 | dict_data = { 'a': 'b' } 18 | response = output_json(dict_data, 200, headers) 19 | 20 | assert json.loads(response.data) == dict_data 21 | assert response.status == '200 OK' 22 | assert response.headers.get('hi') == 'there' 23 | 24 | str_data = 'blah' 25 | response = output_json(str_data, 201) 26 | 27 | assert json.loads(response.data) == { 'message': str_data } 28 | assert response.status == '201 CREATED' 29 | 30 | list_data = ['a', 'b', 'c'] 31 | response = output_json(list_data, 200) 32 | 33 | assert json.loads(response.data) == { 'array': list_data } 34 | 35 | tuple_data = ('a', 'b', 'c') 36 | response = output_json(tuple_data, 200) 37 | 38 | assert json.loads(response.data) == { 'array': ['a', 'b', 'c'] } 39 | 40 | generator_data = (char for char in 'abc') 41 | response = output_json(generator_data, 200) 42 | 43 | assert json.loads(response.data) == { 'array': ['a', 'b', 'c'] } 44 | -------------------------------------------------------------------------------- /gae/app/api/v1/resources/names.py: -------------------------------------------------------------------------------- 1 | from flask.ext.restful import \ 2 | Resource, fields, marshal_with, reqparse 3 | from google.appengine.ext import ndb 4 | from app.models import name 5 | from app.utils.reqparse import string_length 6 | from app.utils.func import compose 7 | # from app.utils.werkzeug_debugger import werkzeug_debugger 8 | 9 | 10 | name_validation = compose( 11 | name.Name.ensure_name_not_in_datastore, 12 | string_length(min_len=1, max_len=10) 13 | ) 14 | 15 | 16 | request_parser = reqparse.RequestParser( 17 | trim=True, 18 | bundle_errors=True, 19 | namespace_class=dict 20 | ) 21 | request_parser.add_argument( 22 | 'name', 23 | type=name_validation, 24 | # location='json', 25 | required=True 26 | ) 27 | 28 | 29 | response_body_schema = { 30 | 'name': fields.String 31 | } 32 | 33 | 34 | class Name(Resource): 35 | decorators=[ marshal_with(response_body_schema) ] 36 | 37 | def get(self): 38 | "return random Name" 39 | random_name = name.Name.random_key().get() 40 | return random_name 41 | 42 | @ndb.transactional 43 | def post(self): 44 | "create and return name; maintain no more than 11 names in datastore" 45 | kwargs = request_parser.parse_args() 46 | name_key = name.Name(parent=name.root, **kwargs).put() 47 | new_name = name_key.get() 48 | 49 | if name.Name.query(ancestor=name.root).count() > 10: 50 | name.Name.random_key(excluding=name_key).delete() 51 | 52 | return new_name 53 | -------------------------------------------------------------------------------- /browser_client/src/app/middleware/api.js: -------------------------------------------------------------------------------- 1 | import fetchMiddleware from './fetch' 2 | import { ActionTypes as T } from 'app/actions' 3 | 4 | 5 | export const API_CALL = Symbol('api-middleware') 6 | 7 | 8 | let _beforeFetch = fetchCall => ({ 9 | ...fetchCall, 10 | credentials: 'same-origin', 11 | headers: { 12 | ...(fetchCall.headers || {}), 13 | 'Accept': 'application/json', 14 | 'Content-Type': 'application/json', 15 | }, 16 | body: fetchCall.body ? JSON.stringify(fetchCall.body) : undefined, 17 | }) 18 | 19 | 20 | let _onFetchFail = (body, response, fetchCall, _action, dispatch) => { 21 | if (response.status >= 500) { 22 | if (__DEV__) { 23 | require('app/utils/devFetchDebug')(body, response.url, fetchCall) 24 | } 25 | 26 | dispatch({ type: T.SERVER_ERROR }) 27 | 28 | return [ undefined, response ] 29 | } else { // 400-level HTTP status 30 | return [ body, response ] 31 | } 32 | } 33 | 34 | 35 | let _onNetworkError = (_error, _fetchCall, _action, dispatch) => { 36 | dispatch({ type: T.NETWORK_ERROR }) 37 | return [] 38 | } 39 | 40 | 41 | if (__TEST__) { 42 | const { deepFreezeFunction } = require('app/utils/deepFreeze') 43 | _beforeFetch = deepFreezeFunction(_beforeFetch) 44 | _onFetchFail = deepFreezeFunction(_onFetchFail) 45 | _onNetworkError = deepFreezeFunction(_onNetworkError) 46 | } 47 | 48 | 49 | export const beforeFetch = _beforeFetch 50 | export const onFetchFail = _onFetchFail 51 | export const onNetworkError = _onNetworkError 52 | 53 | 54 | export default fetchMiddleware({ 55 | key: API_CALL, 56 | beforeFetch, 57 | onFetchFail, 58 | onNetworkError, 59 | }) 60 | -------------------------------------------------------------------------------- /browser_client/src/app/components/GreetingControls.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react' 2 | import Component from 'react-pure-render/component' 3 | import RaisedButton from 'material-ui/lib/raised-button' 4 | import { ColumnWise } from './Flex' 5 | 6 | 7 | const buttonMargin = { marginBottom: 5 } 8 | const waitingStyle = { visibility: 'visible' } 9 | const notWaitingStyle = { visibility: 'hidden' } 10 | 11 | 12 | export default class GreetingControls extends Component { 13 | constructor (props) { 14 | super(props) 15 | this.addName = () => this.props.addName() 16 | this.subtractLastName = () => this.props.subtractLastName() 17 | } 18 | 19 | render () { 20 | const { requestsPending } = this.props 21 | 22 | return ( 23 | 27 | 28 | 33 | 34 | 39 | 40 |
43 | Waiting... 44 |
45 | 46 |
47 | ) 48 | } 49 | } 50 | 51 | 52 | GreetingControls.propTypes = { 53 | requestsPending: PropTypes.bool.isRequired, 54 | addName: PropTypes.func.isRequired, 55 | subtractLastName: PropTypes.func.isRequired, 56 | } 57 | -------------------------------------------------------------------------------- /browser_client/src/app/components/Navigation.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react' 2 | import Component from 'react-pure-render/component' 3 | import IconMenu from 'material-ui/lib/menus/icon-menu' 4 | import MenuItem from 'material-ui/lib/menus/menu-item' 5 | import IconButton from 'material-ui/lib/icon-button' 6 | import MenuIcon from 'material-ui/lib/svg-icons/navigation/menu' 7 | import { HOME, SHIRE, MORDOR } from 'app/utils/Routes' 8 | 9 | 10 | const MenuButtonElement = 11 | 12 | 13 | // currently not using "optimisation.react.constantElements" in 14 | // .babelrc "env.production.optional" because this element is 15 | // considered constant by the optimization, and therefore in 16 | // production we do not see the animation run when the 17 | // navigation links first appear on the page 18 | 19 | 20 | export default class Navigation extends Component { 21 | constructor (props, context) { 22 | super(props, context) 23 | this.navigate = this.navigate.bind(this) 24 | } 25 | 26 | componentWillMount () { 27 | this.history = this.context.history 28 | } 29 | 30 | render () { 31 | return ( 32 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | ) 43 | } 44 | 45 | navigate (_, item) { 46 | this.history.pushState(null, item.props.route) 47 | } 48 | } 49 | 50 | 51 | Navigation.contextTypes = { 52 | history: PropTypes.object.isRequired, 53 | } 54 | -------------------------------------------------------------------------------- /browser_client/src/app/actions/ActionCreators.js: -------------------------------------------------------------------------------- 1 | import { createAction } from 'redux-actions' 2 | import T from './ActionTypes' 3 | import { API_CALL } from 'app/middleware/api' 4 | import { uniqueId } from 'app/utils/lodash' 5 | // uniqueId could be used to help indicate to the reducers 6 | // when a particular optimistic update has finished: 7 | // the optimistic update and the real update will share 8 | // the same unique ID, which is information that can 9 | // help to coordinate a loading animation on the UI 10 | 11 | 12 | export const windowData = createAction(T.WINDOW_DATA) 13 | export const subtractLastName = createAction(T.SUBTRACT_LAST_NAME) 14 | export const clearServerError = createAction(T.CLEAR_SERVER_ERROR) 15 | export const clearNetworkError = createAction(T.CLEAR_NETWORK_ERROR) 16 | export const clearServerValidation = createAction(T.CLEAR_SERVER_VALIDATION) 17 | 18 | 19 | export const addName = createAction( 20 | T.ADD_NAME, 21 | 22 | undefined, 23 | 24 | name => { 25 | const requestId = uniqueId('addName') 26 | 27 | const fetchCall = { 28 | endpoint: '/api/v1/names/', 29 | method: name ? 'POST' : 'GET', 30 | body: name ? { name } : undefined, 31 | done: responseBody => addNameDone(responseBody, requestId), 32 | fail: responseBody => addNameFail(responseBody, requestId), 33 | } 34 | 35 | return { 36 | requestId, 37 | [API_CALL]: fetchCall, 38 | } 39 | } 40 | ) 41 | 42 | 43 | export const addNameDone = createAction( 44 | T.ADD_NAME_DONE, 45 | (responseBody) => responseBody, 46 | (_, requestId) => ({ requestId }) 47 | ) 48 | 49 | 50 | export const addNameFail = createAction( 51 | T.ADD_NAME_FAIL, 52 | (responseBody) => responseBody, 53 | (_, requestId) => ({ requestId }) 54 | ) 55 | -------------------------------------------------------------------------------- /browser_client/config/karma.make.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-var */ 2 | 3 | 4 | /* 5 | see: 6 | http://karma-runner.github.io/0.13/config/configuration-file.html 7 | https://github.com/webpack/karma-webpack/tree/master/example 8 | */ 9 | const assign = require('lodash').assign 10 | 11 | 12 | module.exports = function makeKarmaConfig (opts) { 13 | var __E2E__ = Boolean(opts.__E2E__) 14 | var __ALL__ = Boolean(opts.__ALL__) 15 | var __COVERAGE__ = Boolean(opts.__COVERAGE__) 16 | 17 | var entryFile 18 | 19 | if (__E2E__) { 20 | entryFile = 'browser_client/__test__/index.e2e.js' 21 | } else if (__ALL__) { 22 | entryFile = 'browser_client/__test__/index.all.js' 23 | } else { 24 | entryFile = 'browser_client/__test__/index.unit.js' 25 | } 26 | 27 | var reportSlowerThan = (__E2E__ || __ALL__) ? 750 : 150 28 | 29 | var config = { 30 | basePath: '../..', 31 | reportSlowerThan: reportSlowerThan, 32 | frameworks: [ 'mocha', 'chai', 'sinon' ], 33 | reporters: [ 'mocha' ], 34 | browsers: [ 'Chrome' ], 35 | client: { useIframe: false }, 36 | files: [ entryFile ], 37 | preprocessors: { 'browser_client/**/*': [ 'webpack', 'sourcemap' ] }, 38 | webpackMiddleware: { noInfo: true }, 39 | mochaReporter: { 40 | output: 'autowatch', 41 | }, 42 | } 43 | 44 | if (__COVERAGE__) { 45 | return assign({}, config, { 46 | reporters: [ 'mocha', 'coverage' ], 47 | coverageReporter: { 48 | dir: '__coverage_reports__/browser_client', 49 | type: 'html' 50 | }, 51 | webpack: require('./webpack.coverage'), 52 | }) 53 | } else { 54 | return assign({}, config, { 55 | webpack: require('./webpack.test'), 56 | }) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /browser_client/src/app/reducers/reducers.js: -------------------------------------------------------------------------------- 1 | import { handleActions } from 'redux-actions' 2 | import { List, Set } from 'immutable' 3 | import { getWindowWidth } from 'app/utils/dom' 4 | import * as Styles from 'app/utils/styles' 5 | import { ActionTypes as T } from 'app/actions' 6 | 7 | 8 | const emptyList = List() 9 | const emptySet = Set() 10 | const emptyObject = {} 11 | const initialWindowWidth = getWindowWidth() 12 | const initialWindowSize = Styles.windowSize(initialWindowWidth) 13 | 14 | 15 | export const names = handleActions({ 16 | [T.ADD_NAME_DONE]: (state, { payload }) => state.push(payload), 17 | [T.SUBTRACT_LAST_NAME]: state => state.pop(), 18 | }, emptyList) 19 | 20 | 21 | export const serverValidation = handleActions({ 22 | [T.ADD_NAME]: () => emptyObject, 23 | [T.ADD_NAME_DONE]: () => emptyObject, 24 | [T.ADD_NAME_FAIL]: (_, { payload }) => payload || emptyObject, 25 | [T.CLEAR_SERVER_VALIDATION]: () => emptyObject, 26 | }, emptyObject) 27 | 28 | 29 | export const serverError = handleActions({ 30 | [T.SERVER_ERROR]: () => true, 31 | [T.CLEAR_SERVER_ERROR]: () => false, 32 | }, false) 33 | 34 | 35 | export const networkError = handleActions({ 36 | [T.NETWORK_ERROR]: () => true, 37 | [T.CLEAR_NETWORK_ERROR]: () => false, 38 | }, false) 39 | 40 | 41 | export const pendingRequests = handleActions({ 42 | [T.ADD_NAME]: (state, { meta }) => state.add(meta.requestId), 43 | [T.ADD_NAME_DONE]: (state, { meta }) => state.delete(meta.requestId), 44 | [T.ADD_NAME_FAIL]: (state, { meta }) => state.delete(meta.requestId), 45 | }, emptySet) 46 | 47 | 48 | export const windowWidth = handleActions({ 49 | [T.WINDOW_DATA]: (_, { payload }) => payload.windowWidth, 50 | }, initialWindowWidth) 51 | 52 | 53 | export const windowSize = handleActions({ 54 | [T.WINDOW_DATA]: (_, { payload }) => payload.windowSize, 55 | }, initialWindowSize) 56 | -------------------------------------------------------------------------------- /browser_client/__test__/components/GreetingControls-test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | import { deepFreeze } from 'app/utils/deepFreeze' 4 | import RaisedButton from 'material-ui/lib/raised-button' 5 | import GreetingControls from 'app/components/GreetingControls' 6 | 7 | 8 | const setup = (requestsPending = false) => { 9 | const props = deepFreeze({ 10 | requestsPending, 11 | addName: sinon.spy( x => x ), 12 | subtractLastName: sinon.spy( x => x ), 13 | }) 14 | 15 | const wrapper = shallow() 16 | 17 | return { props, wrapper } 18 | } 19 | 20 | 21 | describe('adding a greeting', () => { 22 | it('calls the `addName` action creator', () => { 23 | const { props, wrapper } = setup() 24 | wrapper.find(RaisedButton).first().simulate('touchTap') 25 | expect( props.addName ).to.have.been.calledOnce 26 | }) 27 | }) 28 | 29 | 30 | describe('subtracting a greeting', () => { 31 | it('calls the `subtractLastName` action creator', () => { 32 | const { props, wrapper } = setup() 33 | wrapper.find(RaisedButton).last().simulate('touchTap') 34 | expect( props.subtractLastName ).to.have.been.calledOnce 35 | }) 36 | }) 37 | 38 | 39 | describe('awaiting a greeting', () => { 40 | context('when there is not a pending greeting request', () => { 41 | it('does not show a waiting indicator', () => { 42 | const { wrapper } = setup() 43 | expect( wrapper.find('.waiting').prop('style').visibility ) 44 | .to.equal( 'hidden' ) 45 | }) 46 | }) 47 | 48 | context('when there is a pending greeting request', () => { 49 | it('does show a waiting indicator', () => { 50 | const { wrapper } = setup(true) 51 | expect( wrapper.find('.waiting').prop('style').visibility ) 52 | .to.equal( 'visible' ) 53 | }) 54 | }) 55 | }) 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Project-Specific ### 2 | 3 | TODO.txt 4 | __app_env__/ 5 | gae/static/index.html 6 | gae/static/*.js 7 | gae/static/*.map 8 | __coverage_reports__/ 9 | __stats__/ 10 | 11 | 12 | ### General NodeJS ### 13 | 14 | # Logs 15 | logs 16 | *.log 17 | npm-debug.log* 18 | 19 | # Runtime data 20 | pids 21 | *.pid 22 | *.seed 23 | 24 | # Directory for instrumented libs generated by jscoverage/JSCover 25 | lib-cov 26 | 27 | # Coverage directory used by tools like istanbul 28 | coverage 29 | 30 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 31 | .grunt 32 | 33 | # node-waf configuration 34 | .lock-wscript 35 | 36 | # Compiled binary addons (http://nodejs.org/api/addons.html) 37 | build/Release 38 | 39 | # Dependency directory 40 | # https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git 41 | node_modules 42 | 43 | 44 | ### General Python ### 45 | 46 | # Byte-compiled / optimized / DLL files 47 | __pycache__/ 48 | *.py[cod] 49 | 50 | # C extensions 51 | *.so 52 | 53 | # Distribution / packaging 54 | .Python 55 | env/ 56 | build/ 57 | develop-eggs/ 58 | dist/ 59 | downloads/ 60 | eggs/ 61 | .eggs/ 62 | lib/ 63 | lib64/ 64 | parts/ 65 | sdist/ 66 | var/ 67 | *.egg-info/ 68 | .installed.cfg 69 | *.egg 70 | 71 | # PyInstaller 72 | # Usually these files are written by a python script from a template 73 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 74 | *.manifest 75 | *.spec 76 | 77 | # Installer logs 78 | pip-log.txt 79 | pip-delete-this-directory.txt 80 | 81 | # Unit test / coverage reports 82 | htmlcov/ 83 | .tox/ 84 | .coverage 85 | .coverage.* 86 | .cache 87 | nosetests.xml 88 | coverage.xml 89 | *,cover 90 | 91 | # Translations 92 | *.mo 93 | *.pot 94 | 95 | # Django stuff: 96 | *.log 97 | 98 | # Sphinx documentation 99 | docs/_build/ 100 | 101 | # PyBuilder 102 | target/ 103 | -------------------------------------------------------------------------------- /gae/app/api/v1/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Advice on Flask-RESTful project structure: 3 | 4 | This is a good file for registering all URL paths via `add_resource` and 5 | for registering blueprint-level `before_request`, `after_request`, etc. 6 | 7 | http://flask-restful-cn.readthedocs.org/en/0.3.4/intermediate-usage.html#project-structure 8 | http://flask-restful-cn.readthedocs.org/en/0.3.4/api.html#flask_restful.Api.add_resource 9 | """ 10 | 11 | 12 | from .. import api_blueprint 13 | from .resources.names import Name 14 | from flask import jsonify 15 | from flask.ext.restful import Api 16 | from types import GeneratorType 17 | 18 | 19 | api_errors = { 20 | 'AssertionError': { 21 | 'message': 'sorry, assertion error', 22 | 'status': 400, 23 | 'extra': 'extra' 24 | } 25 | } 26 | 27 | 28 | api = Api(api_blueprint, prefix='/v1', catch_all_404s=True, errors=api_errors) 29 | # NB: the `errors` error handling doesn't happen when the Flask app 30 | # is in debug mode. 31 | # ACTION: make minimal app example and file Github issue. 32 | # However, if we use the error handler below, it does work in debug mode. 33 | # @api_blueprint.errorhandler(AssertionError) 34 | # def not_implemented(_): 35 | # return jsonify({ 'sorry': 'assertion error' }) 36 | 37 | 38 | api.add_resource(Name, '/names/') 39 | 40 | 41 | @api.representation('application/json') 42 | def output_json(data, status_code, headers=None): 43 | if isinstance(data, list): 44 | data = { 'array': data } 45 | elif isinstance(data, tuple): 46 | data = { 'array': list(data) } 47 | elif isinstance(data, GeneratorType): 48 | data = { 'array': list(data) } 49 | elif isinstance(data, (str, unicode)): 50 | data = { 'message': data } 51 | elif not isinstance(data, dict): 52 | raise NotImplementedError() 53 | 54 | response = jsonify(data) 55 | response.status_code = status_code 56 | response.headers.extend(headers or {}) 57 | return response 58 | -------------------------------------------------------------------------------- /browser_client/src/app/components/MordorPage.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react' 2 | import Component from 'react-pure-render/component' 3 | import Greetings from './Greetings' 4 | import GreetingControls from './GreetingControls' 5 | import Paper from 'material-ui/lib/paper' 6 | import ThemeManager from 'material-ui/lib/styles/theme-manager' 7 | import { Themes } from 'app/utils/styles' 8 | import { Frame } from './Flex' 9 | import ImmutablePropTypes from 'react-immutable-proptypes' 10 | 11 | 12 | const MordorTheme = ThemeManager.getMuiTheme(Themes.Mordor) 13 | const darkWhiteText = { color: '#eeeeee' } 14 | 15 | 16 | export default class MordorPage extends Component { 17 | componentDidMount () { 18 | if (this.props.names.size === 0) this.props.addName() 19 | } 20 | 21 | getChildContext () { 22 | return { 23 | muiTheme: MordorTheme, 24 | } 25 | } 26 | 27 | render () { 28 | const { 29 | names, 30 | requestsPending, 31 | addName, 32 | subtractLastName, 33 | } = this.props 34 | 35 | return ( 36 | 37 | 38 | 39 | 40 | 44 | 45 | 47 | 48 | 49 | 50 | 51 | ) 52 | } 53 | } 54 | 55 | 56 | MordorPage.propTypes = { 57 | names: ImmutablePropTypes.listOf( PropTypes.shape({ 58 | name: PropTypes.string.isRequired, 59 | }) ).isRequired, 60 | requestsPending: PropTypes.bool.isRequired, 61 | addName: PropTypes.func.isRequired, 62 | subtractLastName: PropTypes.func.isRequired, 63 | } 64 | 65 | 66 | MordorPage.childContextTypes = { 67 | muiTheme: PropTypes.object.isRequired, 68 | } 69 | -------------------------------------------------------------------------------- /gae/app.yaml: -------------------------------------------------------------------------------- 1 | # This file specifies your Python application's runtime configuration 2 | # including URL routing, versions, static file uploads, etc. See 3 | # https://developers.google.com/appengine/docs/python/config/appconfig 4 | # and 5 | # https://cloud.google.com/appengine/docs/python/config/appconfig 6 | # for details. 7 | 8 | application: your-app-id-here 9 | version: 1 10 | runtime: python27 11 | api_version: 1 12 | threadsafe: yes 13 | 14 | handlers: 15 | - url: /favicon\.ico 16 | secure: always 17 | static_files: favicon.ico 18 | upload: favicon\.ico 19 | 20 | - url: /static/ 21 | secure: always 22 | static_dir: static 23 | 24 | # This handler tells app engine how to route requests to a WSGI application. 25 | # The script value is in the format . 26 | # where is a WSGI application object. 27 | - url: (/api/.*|/_ah/warmup|/_console) 28 | script: main.app 29 | secure: always 30 | 31 | - url: .* 32 | secure: always 33 | static_files: static/index.html 34 | upload: static/index\.html 35 | 36 | skip_files: 37 | # appengine defaults 38 | - ^(.*/)?#.*#$ 39 | - ^(.*/)?.*~$ 40 | - ^(.*/)?.*\.py[co]$ 41 | - ^(.*/)?.*/RCS/.*$ 42 | - ^(.*/)?\..*$ 43 | # custom additions 44 | - ^(config/test\.py)$ 45 | - ^(config/development\.py)$ 46 | - ^(__test__/.*)$ 47 | 48 | builtins: 49 | - remote_api: on 50 | 51 | inbound_services: 52 | - warmup 53 | 54 | # env_variables: 55 | 56 | # # Third party libraries that are included in the App Engine SDK must be listed 57 | # here if you want to use them. See 58 | # https://developers.google.com/appengine/docs/python/tools/libraries27 59 | # and 60 | # https://cloud.google.com/appengine/docs/python/tools/libraries27 61 | # for a list of libraries included in the SDK. Third party libs that are *not* part 62 | # of the App Engine SDK don't need to be listed here, instead add them to your 63 | # project directory, either as a git submodule or as a plain subdirectory. 64 | #libraries: # TODO: add any appengine-hosted Python modules you need 65 | #- name: jinja2 66 | # version: latest 67 | -------------------------------------------------------------------------------- /gae/__test__/test_models.py: -------------------------------------------------------------------------------- 1 | from app.models import name 2 | import pytest 3 | import datetime 4 | from google.appengine.ext.db import datastore_errors 5 | 6 | 7 | def create_name(name_string): 8 | name_key = name.Name(parent=name.root, name=name_string).put() 9 | return name_key 10 | 11 | 12 | def test_name(testbed): 13 | assert len(name.Name.query().fetch()) == 0 14 | create_name('c3po') 15 | assert len(name.Name.query().fetch()) == 1 16 | create_name('r2d2') 17 | assert len(name.Name.query().fetch()) == 2 18 | 19 | 20 | def test_name_validation(testbed): 21 | with pytest.raises(datastore_errors.BadValueError) as excinfo: 22 | name.Name().put() 23 | 24 | assert 'Entity has uninitialized properties: name' == str(excinfo.value) 25 | 26 | 27 | def test_name_created_auto_add(testbed): 28 | c3po = name.Name(name='c3po') 29 | assert c3po.created is None 30 | c3po.put() 31 | assert isinstance(c3po.created, datetime.datetime) 32 | 33 | 34 | def test_name_ensure_name_not_in_datastore(testbed): 35 | return_value = name.Name.ensure_name_not_in_datastore('c3po') 36 | assert return_value == 'c3po' 37 | 38 | create_name('c3po') 39 | 40 | with pytest.raises(ValueError) as excinfo: 41 | name.Name.ensure_name_not_in_datastore('c3po') 42 | 43 | assert '"c3po" already exists' == str(excinfo.value) 44 | 45 | 46 | def test_name_random_key(testbed): 47 | assert len(name.Name.query().fetch()) == 0 48 | 49 | with pytest.raises(IndexError) as excinfo1: 50 | name.Name.random_key() 51 | 52 | assert 'list index out of range' == str(excinfo1.value) 53 | 54 | c3po_key = create_name('c3po') 55 | 56 | assert len(name.Name.query().fetch()) == 1 57 | assert name.Name.random_key().get().name == 'c3po' 58 | 59 | with pytest.raises(IndexError) as excinfo2: 60 | name.Name.random_key(excluding=c3po_key) 61 | 62 | assert 'list index out of range' == str(excinfo2.value) 63 | 64 | create_name('r2d2') 65 | 66 | assert len(name.Name.query().fetch()) == 2 67 | assert name.Name.random_key(excluding=c3po_key).get().name == 'r2d2' 68 | assert name.Name.random_key().get().name in ('c3po', 'r2d2') 69 | -------------------------------------------------------------------------------- /browser_client/src/app/components/Greetings.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react' 2 | import Component from 'react-pure-render/component' 3 | import { VelocityTransitionGroup } from 'velocity-react' 4 | import CircularProgress from 'material-ui/lib/circular-progress' 5 | import Greeting from './Greeting' 6 | import { randomElement } from 'app/utils/array' 7 | import { memoize } from 'app/utils/lodash' 8 | import ImmutablePropTypes from 'react-immutable-proptypes' 9 | 10 | 11 | const circularProgress = 12 | 13 | 14 | export default class Greetings extends Component { 15 | constructor (props) { 16 | super(props) 17 | /* eslint-disable no-unused-vars */ 18 | this.chooseSalutation = memoize( key => randomElement(salutations) ) 19 | } 20 | 21 | render () { 22 | const { names, requestsPending } = this.props 23 | return ( 24 |
25 | 29 | 30 | {names.map( (name, index) => 31 | 32 | 36 | 37 | )} 38 | 39 | 40 | 41 | {requestsPending ? circularProgress : null} 42 |
43 | ) 44 | } 45 | } 46 | 47 | 48 | Greetings.propTypes = { 49 | names: ImmutablePropTypes.listOf( PropTypes.shape({ 50 | name: PropTypes.string.isRequired, 51 | }) ).isRequired, 52 | requestsPending: PropTypes.bool, 53 | } 54 | 55 | 56 | const salutations = [ 'Hello', 'Hi', 'Hey', 'Yo', ] 57 | 58 | 59 | const defaultAnimationOpts = { 60 | duration: 300, 61 | easing: 'easeOutExpo', 62 | } 63 | 64 | 65 | const greetingEnter = { 66 | ...defaultAnimationOpts, 67 | animation: { 68 | translateY: [0, 30], 69 | opacity: [1, 0], 70 | }, 71 | } 72 | 73 | 74 | const greetingLeave = { 75 | ...defaultAnimationOpts, 76 | animation: { 77 | translateY: [30, 0], 78 | opacity: [0, 1], 79 | }, 80 | } 81 | -------------------------------------------------------------------------------- /browser_client/src/app/components/ShirePage.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react' 2 | import Component from 'react-pure-render/component' 3 | import Greetings from './Greetings' 4 | import GreetingControls from './GreetingControls' 5 | import Paper from 'material-ui/lib/paper' 6 | import ThemeManager from 'material-ui/lib/styles/theme-manager' 7 | import { Themes } from 'app/utils/styles' 8 | import { Frame, Container } from './Flex' 9 | import ImmutablePropTypes from 'react-immutable-proptypes' 10 | 11 | 12 | const ShireTheme = ThemeManager.getMuiTheme(Themes.Shire) 13 | 14 | 15 | export default class ShirePage extends Component { 16 | componentDidMount () { 17 | if (this.props.names.size === 0) this.props.addName() 18 | } 19 | 20 | getChildContext () { 21 | return { 22 | muiTheme: ShireTheme, 23 | } 24 | } 25 | 26 | render () { 27 | const { 28 | names, 29 | requestsPending, 30 | addName, 31 | subtractLastName, 32 | } = this.props 33 | 34 | return ( 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | ) 62 | } 63 | } 64 | 65 | 66 | ShirePage.propTypes = { 67 | names: ImmutablePropTypes.listOf( PropTypes.shape({ 68 | name: PropTypes.string.isRequired, 69 | }) ).isRequired, 70 | requestsPending: PropTypes.bool.isRequired, 71 | addName: PropTypes.func.isRequired, 72 | subtractLastName: PropTypes.func.isRequired, 73 | } 74 | 75 | 76 | ShirePage.childContextTypes = { 77 | muiTheme: PropTypes.object.isRequired, 78 | } 79 | -------------------------------------------------------------------------------- /browser_client/src/app/containers/WindowResizeListener.js: -------------------------------------------------------------------------------- 1 | import { PropTypes } from 'react' 2 | import Component from 'react-pure-render/component' 3 | import { on, off } from 'material-ui/lib/utils/events' 4 | import { debounce } from 'app/utils/lodash' 5 | import { getWindowWidth } from 'app/utils/dom' 6 | import { windowSize } from 'app/utils/styles' 7 | import { connect } from 'react-redux' 8 | import { ActionCreators } from 'app/actions' 9 | 10 | 11 | const { windowData } = ActionCreators 12 | 13 | 14 | /* 15 | * Concept: React components handle DOM events from the user 16 | * and send off relevant data to the store. Window resizing 17 | * is a user action via the DOM, telling the app, "Hey, I'm 18 | * adjusting my window size, please adjust your styling accordingly." 19 | */ 20 | export class WindowResizeListener extends Component { 21 | constructor (props) { 22 | super(props) 23 | 24 | this.sendWindowData = this.sendWindowData.bind(this) 25 | 26 | this.debouncedSendWindowData = debounce( 27 | this.sendWindowData, 28 | this.props.debounceTime, 29 | { leading: false, trailing: true, maxWait: this.props.debounceTime * 2 } 30 | ) 31 | } 32 | 33 | sendWindowData () { 34 | const windowWidth = getWindowWidth() 35 | this.props.windowData({ windowWidth, windowSize: windowSize(windowWidth) }) 36 | } 37 | 38 | componentWillMount () { 39 | on(window, 'resize', this.debouncedSendWindowData) 40 | // Now we're listening to the window-resize event. 41 | // For performance over correctness, we do not actually 42 | // query the window size upon `componentWillMount` since 43 | // our Redux store will have done so on its initialization 44 | // just a fraction of a second prior to this component 45 | // mounting. 46 | } 47 | 48 | componentWillUnmount () { 49 | off(window, 'resize', this.debouncedSendWindowData) 50 | } 51 | 52 | render () { 53 | return null 54 | } 55 | } 56 | 57 | 58 | WindowResizeListener.propTypes = { 59 | debounceTime: PropTypes.number.isRequired, 60 | windowData: PropTypes.func.isRequired, 61 | } 62 | 63 | 64 | WindowResizeListener.defaultProps = { 65 | debounceTime: 350, 66 | } 67 | 68 | 69 | export default connect(undefined, { windowData })(WindowResizeListener) 70 | -------------------------------------------------------------------------------- /browser_client/src/app/containers/CriticalErrorAlert.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react' 2 | import Component from 'react-pure-render/component' 3 | import Dialog from 'material-ui/lib/dialog' 4 | import FlatButton from 'material-ui/lib/flat-button' 5 | import { connect } from 'react-redux' 6 | import { ActionCreators } from 'app/actions' 7 | 8 | 9 | const { clearServerError, clearNetworkError } = ActionCreators 10 | 11 | 12 | export class CriticalErrorAlert extends Component { 13 | render () { 14 | const { 15 | serverError, 16 | networkError, 17 | clearServerError, 18 | clearNetworkError, 19 | } = this.props 20 | 21 | const open = serverError || networkError 22 | 23 | let closeAction 24 | if (open) { 25 | closeAction = serverError ? clearServerError : clearNetworkError 26 | } 27 | 28 | let message 29 | if (serverError) { 30 | message = `A problem occurred on the server. 31 | Please try again later.` 32 | } else if (networkError) { 33 | message = `Cannot connect to internet. 34 | Please ensure you're connected and try again.` 35 | } 36 | 37 | const closeButton = ( 38 | 43 | ) 44 | 45 | // TODO: no need to wrap `closeAction` in array 46 | // after upgrading to `material-ui` v0.14.x 47 | 48 | return ( 49 | 54 | 55 | {message} 56 | 57 | 58 | ) 59 | } 60 | } 61 | 62 | 63 | CriticalErrorAlert.propTypes = { 64 | serverError: PropTypes.bool.isRequired, 65 | networkError: PropTypes.bool.isRequired, 66 | clearServerError: PropTypes.func.isRequired, 67 | clearNetworkError: PropTypes.func.isRequired, 68 | } 69 | 70 | 71 | const mapStateToProps = ({ serverError, networkError }) => 72 | ({ serverError, networkError }) 73 | 74 | 75 | export default connect(mapStateToProps, { 76 | clearServerError, clearNetworkError 77 | })(CriticalErrorAlert) 78 | -------------------------------------------------------------------------------- /gae/__test__/test_utils.py: -------------------------------------------------------------------------------- 1 | from app.utils import func, reqparse, decorator, werkzeug_debugger 2 | import pytest 3 | 4 | 5 | def test_func_compose(): 6 | a = lambda x: x ** 3 7 | b = lambda x: x * -4 8 | c = lambda x: x + 5 9 | assert func.compose(a, b, c)(5) == a(b(c(5))) 10 | 11 | w = lambda s: s.upper() 12 | x = lambda s: ''.join(reversed(s))[-2:] 13 | y = lambda s: s + 'abc' 14 | z = lambda s: s * 3 15 | assert func.compose(w, x, y, z)('abc') == w(x(y(z('abc')))) 16 | 17 | 18 | def test_reqparse_string_length(): 19 | no_validation = reqparse.string_length() 20 | assert no_validation('') == '' 21 | assert no_validation('a' * 25) == 'a' * 25 22 | 23 | yes_validation = reqparse.string_length(min_len=1, max_len=10) 24 | 25 | assert yes_validation('c3po') == 'c3po' 26 | 27 | with pytest.raises(ValueError) as excinfo: 28 | yes_validation('') 29 | 30 | assert 'must be at least 1 character long' == str(excinfo.value) 31 | 32 | with pytest.raises(ValueError) as excinfo: 33 | yes_validation('a' * 11) 34 | 35 | assert 'must be no more than 10 characters long' == str(excinfo.value) 36 | 37 | 38 | def test_decorator(): 39 | @decorator.decorator 40 | def a(f): 41 | "a's doc string" 42 | def _f(): 43 | return 'a' + f() 44 | 45 | return _f 46 | 47 | @decorator.decorator 48 | def b(f): 49 | def _f(): 50 | return 'b' + f() 51 | 52 | return _f 53 | 54 | @a 55 | @b 56 | def c(): 57 | "c's doc string" 58 | return 'c' 59 | 60 | unwrapped_a = a.func_dict['__wrapped__'] 61 | assert a.func_name == unwrapped_a.func_name == 'a' 62 | assert a.func_doc == unwrapped_a.func_doc == "a's doc string" 63 | 64 | unwrapped_c = c.func_dict['__wrapped__'] 65 | assert c() == 'abc' 66 | assert unwrapped_c() == 'c' 67 | assert c.func_name == unwrapped_c.func_name == 'c' 68 | assert c.func_doc == unwrapped_c.func_doc == "c's doc string" 69 | 70 | 71 | def test_werkzeug_debugger(): 72 | from flask import Flask 73 | app = Flask(__name__) 74 | 75 | with app.app_context(): 76 | werkzeug_debugger.werkzeug_debugger() 77 | 78 | app.debug = True 79 | 80 | with app.app_context(): 81 | with pytest.raises(AssertionError) as excinfo: 82 | werkzeug_debugger.werkzeug_debugger() 83 | 84 | assert 'werkzeug_debugger()' == str(excinfo.value) 85 | -------------------------------------------------------------------------------- /browser_client/src/app/utils/styles.js: -------------------------------------------------------------------------------- 1 | import Colors from 'material-ui/lib/styles/colors' 2 | import ColorManipulator from 'material-ui/lib/utils/color-manipulator' 3 | import Spacing from 'material-ui/lib/styles/spacing' 4 | import LightRawTheme from 'material-ui/lib/styles/raw-themes/light-raw-theme' 5 | 6 | 7 | // thanks to material-ui 8 | export function windowSize (windowWidth) { 9 | if (windowWidth >= WindowWidthThresholds.LARGE) { 10 | return WindowSizes.LARGE 11 | } else if (windowWidth >= WindowWidthThresholds.MEDIUM) { 12 | return WindowSizes.MEDIUM 13 | } else { 14 | return WindowSizes.SMALL 15 | } 16 | } 17 | 18 | 19 | export const WindowWidthThresholds = { 20 | MEDIUM: 768, 21 | LARGE: 992, 22 | } 23 | 24 | 25 | export const WindowSizes = { 26 | SMALL: 1, 27 | MEDIUM: 2, 28 | LARGE: 3, 29 | } 30 | 31 | 32 | const Default = { 33 | spacing: Spacing, 34 | fontFamily: 'Roboto, sans-serif', 35 | palette: Object.assign({}, LightRawTheme.palette, { 36 | canvasColor: '#fdfdfd', 37 | }), 38 | } 39 | 40 | 41 | const Shire = { 42 | spacing: Spacing, 43 | fontFamily: 'Roboto, sans-serif', 44 | palette: { 45 | primary1Color: Colors.green500, 46 | primary2Color: Colors.green700, 47 | primary3Color: Colors.lightBlack, 48 | accent1Color: Colors.amberA700, 49 | accent2Color: Colors.grey100, 50 | accent3Color: Colors.grey500, 51 | textColor: Colors.darkBlack, 52 | alternateTextColor: Colors.white, 53 | canvasColor: Colors.green100, 54 | borderColor: Colors.grey300, 55 | disabledColor: ColorManipulator.fade(Colors.darkBlack, 0.3), 56 | }, 57 | } 58 | 59 | 60 | const Mordor = { 61 | spacing: Spacing, 62 | fontFamily: 'Roboto, sans-serif', 63 | palette: { 64 | primary1Color: Colors.blueGrey500, 65 | primary2Color: Colors.blueGrey700, 66 | primary3Color: Colors.grey600, 67 | accent1Color: Colors.deepOrangeA400, 68 | accent2Color: Colors.deepOrangeA700, 69 | accent3Color: Colors.deepOrangeA200, 70 | textColor: Colors.fullWhite, 71 | alternateTextColor: Colors.darkWhite, 72 | canvasColor: '#303030', 73 | borderColor: ColorManipulator.fade(Colors.fullWhite, 0.3), 74 | disabledColor: ColorManipulator.fade(Colors.fullWhite, 0.3) 75 | }, 76 | } 77 | 78 | 79 | export const Themes = { Shire, Default, Mordor } 80 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | 4 | "extends": [ 5 | "eslint:recommended", 6 | "standard", 7 | "standard-react" 8 | ], 9 | 10 | "plugins": [ 11 | "react" 12 | ], 13 | 14 | "ecmaFeatures": { 15 | "modules": true, 16 | "jsx": true, 17 | "experimentalObjectRestSpread": true 18 | }, 19 | 20 | "env": { 21 | "es6": true, 22 | "browser": true, 23 | "mocha": true, 24 | "node": true 25 | }, 26 | 27 | "globals": { 28 | "__DEV__": false, 29 | "__TEST__": false, 30 | "expect": false, 31 | "chai": false, 32 | "sinon": false 33 | }, 34 | 35 | "rules": { 36 | "comma-dangle": 0, 37 | "consistent-return": 2, 38 | "constructor-super": 2, 39 | "eol-last": 2, 40 | "eqeqeq": 2, 41 | "indent": [ 0, 4 ], 42 | "jsx-quotes": [ 2, "prefer-double" ], 43 | "key-spacing": 0, 44 | "linebreak-style": [ 2, "unix" ], 45 | "no-cond-assign": 2, 46 | "no-console": 2, 47 | "no-debugger": 2, 48 | "no-mixed-requires": 2, 49 | "no-mixed-spaces-and-tabs": 2, 50 | "no-multiple-empty-lines": [2, {"max": 2, "maxEOF": 1}], 51 | "no-multi-spaces": 0, 52 | "no-this-before-super": 2, 53 | "no-unused-vars": 2, 54 | "no-var": 2, 55 | "prefer-arrow-callback": 2, 56 | "prefer-const": 2, 57 | "prefer-reflect": 2, 58 | "prefer-template": 2, 59 | "quotes": [ 2, "single" ], 60 | "react/forbid-prop-types": 2, 61 | "react/jsx-boolean-value": 0, 62 | "react/jsx-indent": [ 2, 4 ], 63 | "react/jsx-key": 2, 64 | "react/jsx-no-bind": 2, 65 | "react/jsx-no-duplicate-props": 2, 66 | "react/jsx-no-undef": 2, 67 | "react/jsx-uses-react": 2, 68 | "react/jsx-uses-vars": 2, 69 | "react/no-deprecated": 2, 70 | "react/no-did-mount-set-state": 2, 71 | "react/no-did-update-set-state": 2, 72 | "react/no-direct-mutation-state": 2, 73 | "react/no-is-mounted": 2, 74 | "react/no-string-refs": 2, 75 | "react/no-unknown-property": 2, 76 | "react/prop-types": 2, 77 | "react/react-in-jsx-scope": 2, 78 | "react/wrap-multilines": 2, 79 | "require-yield": 2, 80 | "semi": [ 2, "never" ], 81 | "space-in-parens": 0, 82 | "vars-on-top": 0 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /browser_client/__test__/utils/deepFreeze-test.js: -------------------------------------------------------------------------------- 1 | import { 2 | deepFreezeFunction, 3 | deepFreezeFunctions, 4 | } from 'app/utils/deepFreeze' 5 | 6 | 7 | const setup = () => ({ 8 | pure: obj => ({ ...obj, foo: 'bar' }), 9 | impure: obj => Object.assign(obj, { foo: 'bar' }), 10 | }) 11 | 12 | 13 | describe('`deepFreezeFunction`', () => { 14 | it('deep freezes the returned value of the function it wraps', () => { 15 | const { pure } = setup() 16 | 17 | const notFrozen = pure({ a: { b: { c: 'c' } } }) 18 | expect( notFrozen.foo ).to.equal( 'bar' ) 19 | 20 | const p = deepFreezeFunction(pure) 21 | const frozen = p({ a: { b: { c: 'c' } } }) 22 | expect( frozen.foo ).to.equal( 'bar' ) 23 | 24 | expect( Object.isFrozen(notFrozen) ).to.be.false 25 | expect( Object.isFrozen(frozen) ).to.be.true 26 | expect( () => { frozen.a.b.c = 'd' } ).to.throw( Error ) 27 | }) 28 | 29 | it('deep freezes the arguments passed in', () => { 30 | const { impure } = setup() 31 | 32 | const ip = deepFreezeFunction(impure) 33 | 34 | expect( () => impure({}) ).to.not.throw( Error ) 35 | expect( () => ip({}) ).to.throw( Error ) 36 | }) 37 | 38 | it('does not otherwise change the function signature', () => { 39 | const { pure } = setup() 40 | 41 | const normalReturnValue = pure({ a: 'b' }) 42 | const frozenReturnValue = deepFreezeFunction(pure)({ a: 'b' }) 43 | 44 | expect( frozenReturnValue ).to.deep.equal( normalReturnValue ) 45 | expect( Object.isFrozen(frozenReturnValue) ).to.be.true 46 | expect( Object.isFrozen(normalReturnValue) ).to.be.false 47 | }) 48 | }) 49 | 50 | 51 | describe('`deepFreezeFunctions`', () => { 52 | it('deep freezes all functions in an object', () => { 53 | const { pure, impure } = setup() 54 | 55 | const frozenFns = deepFreezeFunctions({ pure, impure }) 56 | const { pure: p, impure: ip } = frozenFns 57 | 58 | expect( Object.isFrozen(p({})) ).to.be.true 59 | expect( () => ip({}) ).to.throw( Error ) 60 | }) 61 | 62 | it('does not alter non-functions', () => { 63 | const { pure } = setup() 64 | 65 | const originalObject = {} 66 | const originalArray = [] 67 | const { obj, array } = deepFreezeFunctions({ 68 | pure, 69 | obj: originalObject, 70 | array: originalArray, 71 | }) 72 | 73 | expect( Object.isFrozen(obj) ).to.be.false 74 | expect( Object.isFrozen(array) ).to.be.false 75 | expect( obj ).to.equal( originalObject ) 76 | expect( array ).to.equal( originalArray ) 77 | }) 78 | }) 79 | -------------------------------------------------------------------------------- /gae/__test__/test_app.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import json 3 | from app.models import name 4 | from app import create_app 5 | from config import config 6 | 7 | 8 | @pytest.fixture(scope='function') 9 | def app(): 10 | return create_app(config['test']) 11 | 12 | 13 | @pytest.fixture(scope='function') 14 | def client(app): 15 | return app.test_client() 16 | 17 | 18 | def test_warmup(client): 19 | response = client.get('/_ah/warmup') 20 | assert response.data == 'Warmed up!' 21 | assert response.status == '200 OK' 22 | 23 | 24 | def test_get_name(client, testbed): 25 | name.Name(parent=name.root, name='c3po').put() 26 | response = client.get('/api/v1/names/') 27 | assert json.loads(response.data) == { 'name': 'c3po' } 28 | assert response.status == '200 OK' 29 | 30 | 31 | def test_get_name_no_data(client, testbed): 32 | with pytest.raises(IndexError) as excinfo: 33 | client.get('/api/v1/names/') 34 | 35 | assert 'list index out of range' == str(excinfo.value) 36 | 37 | 38 | def test_post_name(client, testbed): 39 | assert len(name.Name.query().fetch()) == 0 40 | 41 | response = client.post('/api/v1/names/', data={ 'name': 'c3po' }) 42 | 43 | assert len(name.Name.query().fetch()) == 1 44 | assert json.loads(response.data) == { 'name': 'c3po' } 45 | assert response.status == '200 OK' 46 | 47 | 48 | def test_post_name_maintains_max_11_names(client, testbed): 49 | assert len(name.Name.query().fetch()) == 0 50 | 51 | name_template = 'c3po{}' 52 | for n in xrange(11): 53 | name.Name(parent=name.root, name=name_template.format(n)).put() 54 | 55 | assert len(name.Name.query().fetch()) == 11 56 | 57 | client.post('/api/v1/names/', data={ 'name': 'r2d2' }) 58 | 59 | assert len(name.Name.query().fetch()) == 11 60 | assert name.Name.query(name.Name.name == 'r2d2').count() == 1 61 | 62 | 63 | def test_post_name_collision(client, testbed): 64 | response = client.post('/api/v1/names/', data={ 'name': 'c3po' }) 65 | 66 | assert len(name.Name.query().fetch()) == 1 67 | assert response.status == '200 OK' 68 | 69 | response = client.post('/api/v1/names/', data={ 'name': 'c3po' }) 70 | 71 | assert len(name.Name.query().fetch()) == 1 72 | assert response.status == '400 BAD REQUEST' 73 | assert json.loads(response.data) == { 74 | 'message': { 'name': '"c3po" already exists' } 75 | } 76 | 77 | 78 | def test_post_name_bad_data(client, testbed): 79 | assert len(name.Name.query().fetch()) == 0 80 | 81 | too_long_name = 'a' * 20 82 | response = client.post('/api/v1/names/', data={ 'name': too_long_name }) 83 | 84 | assert len(name.Name.query().fetch()) == 0 85 | assert response.status == '400 BAD REQUEST' 86 | assert json.loads(response.data) == { 87 | 'message': { 'name': 'must be no more than 10 characters long' } 88 | } 89 | 90 | too_short_name = '' 91 | response = client.post('/api/v1/names/', data={ 'name': too_short_name }) 92 | 93 | assert len(name.Name.query().fetch()) == 0 94 | assert response.status == '400 BAD REQUEST' 95 | assert json.loads(response.data) == { 96 | 'message': { 'name': 'must be at least 1 character long' } 97 | } 98 | -------------------------------------------------------------------------------- /browser_client/src/app/components/HomePage.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react' 2 | import Component from 'react-pure-render/component' 3 | import AddNameForm from './AddNameForm' 4 | import Greetings from './Greetings' 5 | import Paper from 'material-ui/lib/paper' 6 | import Block from 'jsxstyle/Block' 7 | import { Frame, Container } from './Flex' 8 | import ThemeManager from 'material-ui/lib/styles/theme-manager' 9 | import { Themes } from 'app/utils/styles' 10 | import ImmutablePropTypes from 'react-immutable-proptypes' 11 | 12 | 13 | const DefaultTheme = ThemeManager.getMuiTheme(Themes.Default) 14 | 15 | 16 | export default class HomePage extends Component { 17 | constructor (props) { 18 | super(props) 19 | this.refAddNameForm = c => this.addNameForm = c 20 | } 21 | 22 | getChildContext () { 23 | return { 24 | muiTheme: DefaultTheme, 25 | } 26 | } 27 | 28 | componentDidUpdate (prevProps) { 29 | const { pageHasEntered } = this.props 30 | if (pageHasEntered && !prevProps.pageHasEntered) { 31 | this.addNameForm.focus() 32 | } 33 | } 34 | 35 | render () { 36 | const { 37 | names, 38 | serverValidation, 39 | addName, 40 | requestsPending, 41 | clearServerValidation, 42 | } = this.props 43 | 44 | return ( 45 | 46 | 47 | 48 | 49 | 50 | 53 | 54 | 55 | 60 | 61 | 62 | 63 | 64 | 65 |

Below the Fold

66 |
67 |
68 | ) 69 | } 70 | } 71 | 72 | 73 | HomePage.propTypes = { 74 | pageHasEntered: PropTypes.bool.isRequired, 75 | names: ImmutablePropTypes.listOf( PropTypes.shape({ 76 | name: PropTypes.string.isRequired, 77 | }) ).isRequired, 78 | serverValidation: PropTypes.shape({ 79 | message: PropTypes.shape({ 80 | name: PropTypes.string.isRequired, 81 | }), 82 | }).isRequired, 83 | addName: PropTypes.func.isRequired, 84 | requestsPending: PropTypes.bool.isRequired, 85 | clearServerValidation: PropTypes.func.isRequired, 86 | } 87 | 88 | 89 | HomePage.defaultProps = { 90 | pageHasEntered: false, 91 | } 92 | 93 | 94 | HomePage.childContextTypes = { 95 | muiTheme: PropTypes.object.isRequired, 96 | } 97 | -------------------------------------------------------------------------------- /browser_client/src/app/middleware/fetch.js: -------------------------------------------------------------------------------- 1 | /* 2 | adapted from: 3 | https://github.com/rackt/redux/blob/master/examples/real-world/middleware/api.js 4 | */ 5 | 6 | 7 | import { isPlainObject } from 'app/utils/lodash' 8 | 9 | 10 | export const FETCH = Symbol('fetch-middleware') 11 | 12 | 13 | export default (opts = {}) => { 14 | validateOptions(opts) 15 | 16 | const fetchKey = opts.key || FETCH 17 | const beforeFetch = opts.beforeFetch || ( x => x ) 18 | const onFetchDone = opts.onFetchDone || ( (...args) => args ) 19 | const onFetchFail = opts.onFetchFail || ( (...args) => args ) 20 | const onNetworkError = opts.onNetworkError || ( (...args) => args ) 21 | 22 | return ({ dispatch, getState }) => next => action => { 23 | if (!isFetchCall(action, fetchKey)) return next(action) 24 | 25 | const fetchCall = beforeFetch(action.meta[fetchKey], action, dispatch, getState) 26 | 27 | validateFetchCall(fetchCall) 28 | 29 | const { endpoint, done, fail, dispatchBaseAction, ...rest } = fetchCall 30 | 31 | if (dispatchBaseAction !== false) next(action) 32 | 33 | const extraArgs = [ fetchCall, action, dispatch, getState ] 34 | 35 | return fetch(endpoint, rest) 36 | .then( response => { 37 | const bodyTransform = response.status < 500 ? response.json() : response.text() 38 | return bodyTransform.then( body => ({ response, body }) ) 39 | }) 40 | .then( ({ response, body }) => 41 | response.ok 42 | ? dispatch( done( ...onFetchDone(body, response, ...extraArgs) ) ) 43 | : dispatch( fail( ...onFetchFail(body, response, ...extraArgs) ) ) 44 | ) 45 | .catch( error => dispatch( fail( ...onNetworkError(error, ...extraArgs) ) ) ) 46 | } 47 | } 48 | 49 | 50 | const isFetchCall = (action, fetchKey) => 51 | isPlainObject(action) && isPlainObject(action.meta) && (fetchKey in action.meta) 52 | 53 | 54 | const OPTION_KEYS = [ 'beforeFetch', 'onFetchDone', 'onFetchFail', 'onNetworkError' ] 55 | 56 | 57 | const validateOptions = opts => { 58 | if (!isPlainObject(opts)) { 59 | throw new Error('The argument to `fetchMiddleware` must be a plain ' + 60 | 'JavaScript object or left undefined.') 61 | } 62 | 63 | if (typeof opts.key !== 'symbol' && typeof opts.key !== 'undefined') { 64 | throw new Error('`key` must be a symbol or left undefined.') 65 | } 66 | 67 | OPTION_KEYS.forEach( fnName => { 68 | const fn = opts[fnName] 69 | 70 | if (typeof fn !== 'function' && typeof fn !== 'undefined') { 71 | throw new Error(`\`${fnName}\` must be a function or left undefined.`) 72 | } 73 | }) 74 | } 75 | 76 | 77 | const validateFetchCall = fetchCall => { 78 | if (!isPlainObject(fetchCall)) { 79 | throw new Error('`action.meta[]` must be a plain JavaScript object.') 80 | } else if (typeof fetchCall.done !== 'function') { 81 | throw new Error('`action.meta[].done` must be an action-creator function.') 82 | } else if (typeof fetchCall.fail !== 'function') { 83 | throw new Error('`action.meta[].fail` must be an action-creator function.') 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /browser_client/src/app/components/AddNameForm.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react' 2 | import Component from 'react-pure-render/component' 3 | import { Form } from 'formsy-react' 4 | import RaisedButton from 'material-ui/lib/raised-button' 5 | import { ShrinkWrap } from './Flex' 6 | import FormsyText from 'formsy-material-ui/lib/FormsyText' 7 | 8 | 9 | const FormsyTextStyle = { marginBottom: '3rem' } 10 | 11 | 12 | const nameValidationErrors = { 13 | isAlpha: 'You may only use letters', 14 | minLength: 'The name is too short', 15 | maxLength: 'The name is too long', 16 | } 17 | 18 | 19 | // external validation errors can come in from server 20 | // and be passed down as props 21 | // external validations take priority over internal 22 | // form ones; internal form errors will only appear 23 | // if `validationErrors` is falsy 24 | // e.g.: const externalValidationErrors = { 25 | // name: 'some error from server', 26 | // } 27 | // this is another alternative: 28 | // https://github.com/christianalfoni/formsy-react/blob 29 | // /master/API.md#updateinputswitherrorerrors 30 | 31 | 32 | export default class AddNameForm extends Component { 33 | constructor (props) { 34 | super(props) 35 | this.state = { isValid: false } 36 | this.submit = this.submit.bind(this) 37 | this.clearServerValidation = this.clearServerValidation.bind(this) 38 | this.setValid = () => this.setState({ isValid: true }) 39 | this.setInvalid = () => this.setState({ isValid: false }) 40 | this.refNameInput = c => this.nameInput = c 41 | this.focus = () => this.nameInput.focus() 42 | } 43 | 44 | render () { 45 | return ( 46 |
52 | 53 | 54 | 55 | 64 | 65 | 70 | 71 | 72 | 73 |
74 | ) 75 | } 76 | 77 | submit (model, resetForm) { 78 | this.props.addName(model.name) 79 | resetForm() 80 | this.nameInput.focus() 81 | } 82 | 83 | componentWillUnmount () { 84 | this.clearServerValidation() 85 | } 86 | 87 | clearServerValidation () { 88 | if (this.props.serverValidation.message) { 89 | this.props.clearServerValidation() 90 | } 91 | } 92 | } 93 | 94 | 95 | AddNameForm.propTypes = { 96 | addName: PropTypes.func.isRequired, 97 | clearServerValidation: PropTypes.func.isRequired, 98 | serverValidation: PropTypes.shape({ 99 | message: PropTypes.shape({ 100 | name: PropTypes.string.isRequired, 101 | }), 102 | }).isRequired, 103 | } 104 | -------------------------------------------------------------------------------- /browser_client/src/app/containers/index.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react' 2 | import Component from 'react-pure-render/component' 3 | import { Provider } from 'react-redux' 4 | import { Router, Route, IndexRoute, Redirect } from 'react-router' 5 | import HomeApp from './HomeApp' 6 | import ShireApp from './ShireApp' 7 | import MordorApp from './MordorApp' 8 | import WindowResizeListener from './WindowResizeListener' 9 | import CriticalErrorAlert from './CriticalErrorAlert' 10 | import LayoutApp from './LayoutApp' 11 | import { HOME, SHIRE, MORDOR } from 'app/utils/Routes' 12 | 13 | 14 | export default class Root extends Component { 15 | constructor (props) { 16 | super(props) 17 | 18 | this.renderReduxProvider = this.renderReduxProvider.bind(this) 19 | 20 | if (__DEV__) { 21 | this.state = { debugVisible: false } 22 | this.toggleDebugVisible = () => { 23 | this.setState({ debugVisible: !this.state.debugVisible }) 24 | } 25 | } 26 | } 27 | 28 | getChildContext () { 29 | return { history: this.props.history } 30 | } 31 | 32 | render () { 33 | if (__DEV__) { 34 | const { DevTools, DebugPanel, LogMonitor } = require('redux-devtools/lib/react') 35 | const toggleButtonStyle = { 36 | backgroundColor: '#2A2F3A', 37 | color: '#6FB3D2', 38 | position: 'fixed', 39 | bottom: 0, 40 | left: 0 41 | } 42 | 43 | return ( 44 |
45 | 46 | {this.renderReduxProvider()} 47 | 48 | 49 | 50 | 51 | 52 | 57 | 58 |
59 | ) 60 | } else { 61 | return this.renderReduxProvider() 62 | } 63 | } 64 | 65 | renderReduxProvider () { 66 | return ( 67 | 68 |
69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 |
80 |
81 | ) 82 | } 83 | } 84 | 85 | 86 | Root.propTypes = { 87 | history: PropTypes.shape({ 88 | pushState: PropTypes.func.isRequired, 89 | }).isRequired, 90 | store: PropTypes.shape({ 91 | dispatch: PropTypes.func.isRequired, 92 | subscribe: PropTypes.func.isRequired, 93 | getState: PropTypes.func.isRequired, 94 | replaceReducer: PropTypes.func.isRequired, 95 | }).isRequired, 96 | } 97 | 98 | 99 | Root.childContextTypes = { 100 | history: PropTypes.shape({ 101 | pushState: PropTypes.func.isRequired, 102 | }).isRequired, 103 | } 104 | -------------------------------------------------------------------------------- /browser_client/__test__/e2e/greetings-e2e.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable prefer-arrow-callback */ 2 | 3 | 4 | /* 5 | Property-based testing of the "greetings" aspect of the app. 6 | */ 7 | 8 | 9 | import React from 'react' 10 | import ReactDOM from 'react-dom' 11 | import Root from 'app/containers' 12 | import { createStoreWithMiddleware } from 'app/store' 13 | import createBrowserHistory from 'history/lib/createBrowserHistory' 14 | import { createFinder } from '../utils' 15 | import { Simulate } from 'react-addons-test-utils' 16 | import jsc from 'jsverify' 17 | import _ from 'lodash' 18 | import RaisedButton from 'material-ui/lib/raised-button' 19 | import Greeting from 'app/components/Greeting' 20 | import { ActionTypes as T } from 'app/actions' 21 | import { SHIRE, HOME } from 'app/utils/Routes' 22 | 23 | 24 | const returnMockThenable = () => ({ 25 | then: returnMockThenable, 26 | catch: returnMockThenable, 27 | }) 28 | 29 | 30 | const makeMockFetch = store => () => { 31 | store.dispatch({ 32 | type: T.ADD_NAME_DONE, 33 | payload: { name: 'world' }, 34 | meta: {}, 35 | }) 36 | 37 | return returnMockThenable() 38 | } 39 | 40 | 41 | describe('adding and subtracting greetings', function () { 42 | afterEach(function () { 43 | this.appHistory.replaceState(null, HOME) 44 | ReactDOM.unmountComponentAtNode(this.container) 45 | if (window.fetch.restore) window.fetch.restore() 46 | }) 47 | 48 | beforeEach(function () { 49 | const store = createStoreWithMiddleware() 50 | 51 | sinon.stub(window, 'fetch', makeMockFetch(store)) 52 | 53 | /* eslint-disable new-cap */ 54 | this.appHistory = new createBrowserHistory() 55 | this.container = document.createElement('div') 56 | const appRoot = ReactDOM.render( 57 | , 58 | this.container 59 | ) 60 | 61 | findOnPage = createFinder(appRoot) 62 | this.appHistory.pushState(null, SHIRE) 63 | const [ raisedAddButton, raisedSubtractButton ] = findOnPage(RaisedButton) 64 | const addButton = createFinder(raisedAddButton)('button')[0] 65 | const subtractButton = createFinder(raisedSubtractButton)('button')[0] 66 | 67 | clickAddButton = function () { 68 | Simulate.touchTap(addButton) 69 | return findOnPage(Greeting).length 70 | } 71 | 72 | clickSubtractButton = function () { 73 | Simulate.touchTap(subtractButton) 74 | return findOnPage(Greeting).length 75 | } 76 | }) 77 | 78 | let clickAddButton, clickSubtractButton, findOnPage 79 | const [ ADD, SUBTRACT, FLOOR ] = [ 1, -1, 0 ] 80 | 81 | describe('the number of greetings on the page', function () { 82 | this.timeout(3000) // see https://mochajs.org/#timeouts 83 | 84 | const testDescription = 'equals scan of sum of actions with a floor of 0' 85 | 86 | jsc.property(testDescription, 'array bool', function (arrayOfBooleans) { 87 | const initialGreetingsCount = findOnPage(Greeting).length 88 | const inputStream = arrayOfBooleans.map( b => b ? ADD : SUBTRACT ) 89 | const sumScanWithFloor = inputStream.reduce( 90 | (array, action) => array.concat( 91 | [ Math.max(FLOOR, action + array[array.length - 1]) ] 92 | ) 93 | , [ initialGreetingsCount ]) 94 | 95 | const uiStateStream = inputStream.map( action => 96 | (action === ADD ? clickAddButton : clickSubtractButton)() 97 | ) 98 | 99 | uiStateStream.unshift(initialGreetingsCount) 100 | 101 | return _.isEqual(uiStateStream, sumScanWithFloor) 102 | }) 103 | }) 104 | }) 105 | -------------------------------------------------------------------------------- /browser_client/src/app/components/Layout.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react' 2 | import Component from 'react-pure-render/component' 3 | import Navigation from './Navigation' 4 | import { Flex, WindowFrame, ShrinkWrap } from './Flex' 5 | import { imageMarkup } from 'app/utils/images' 6 | import { VelocityTransitionGroup } from 'velocity-react' 7 | 8 | 9 | // Although `React.cloneElement(this.props.children, { ... })` is not a bad pattern 10 | // and is sometimes useful, it is best to first find a data-flow architecture where 11 | // just React owners, not parents, control the props of children. However, in this 12 | // case, the `pageHasEntered` message is very particular to the Layout-child 13 | // relationship where the Layout controls the layout and entrance/exit animation 14 | // of top-level pages that are navigated to. The implementation here, using 15 | // `React.cloneElement(this.props.children, { ... })` is very direct and reflects 16 | // this relationship; there is not undue coupling between Layout and its 17 | // `this.props.children` via `pageHasEntered` because Layout is a top-level 18 | // container-like component that is partly defined by this specific relationship. 19 | // References: 20 | // `React.cloneElement` 21 | // https://facebook.github.io/react/docs/top-level-api.html#react.cloneelement 22 | // React "parent" versus "owner" 23 | // https://facebook.github.io/react/docs/multiple-components.html#ownership 24 | 25 | 26 | export default class Layout extends Component { 27 | constructor (props) { 28 | super(props) 29 | this.state = { enteredPagePath: null } 30 | const complete = () => this.setState({ 31 | enteredPagePath: this.props.location.pathname 32 | }) 33 | this.pageEnter = { 34 | ...pageEnter, 35 | complete 36 | } 37 | } 38 | 39 | render () { 40 | const { 41 | location: { pathname }, 42 | children, 43 | windowSize, 44 | } = this.props 45 | 46 | const { enteredPagePath } = this.state 47 | 48 | const imageUrl = imageMarkup(pathname, windowSize) 49 | const background = imageUrl 50 | ? `${imageUrl} no-repeat center center fixed` 51 | : undefined 52 | 53 | return ( 54 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 68 | 69 |
70 | 71 | {React.cloneElement(children, { 72 | pageHasEntered: enteredPagePath === pathname 73 | })} 74 | 75 |
76 | 77 |
78 |
79 | 80 |
81 | ) 82 | } 83 | } 84 | 85 | 86 | Layout.propTypes = { 87 | windowSize: PropTypes.number.isRequired, 88 | children: PropTypes.element.isRequired, 89 | location: PropTypes.shape({ 90 | pathname: PropTypes.string.isRequired, 91 | }).isRequired, 92 | } 93 | 94 | 95 | const pageStyle = { 96 | position: 'absolute', 97 | top: 0, 98 | bottom: 0, 99 | left: 0, 100 | right: 0, 101 | } 102 | 103 | 104 | const defaultAnimationOpts = { 105 | duration: 1000, 106 | // easing: [ 250, 15 ], // could use spring instead 107 | easing: 'easeOutExpo', 108 | } 109 | 110 | 111 | const pageEnter = { 112 | ...defaultAnimationOpts, 113 | animation: { 114 | translateX: [0, '-100%'], 115 | }, 116 | } 117 | 118 | 119 | const pageLeave = { 120 | ...defaultAnimationOpts, 121 | animation: { 122 | translateX: ['100%', 0], 123 | }, 124 | } 125 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gae-flask-redux-react-starter-kit", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "repl": "$(npm bin)/babel-node", 6 | "harmony": "node --harmony", 7 | "start": "$(npm bin)/webpack-dev-server --config browser_client/config/webpack.dev.js --port 8081 --history-api-fallback --hot --inline --no-info", 8 | "build": "$(npm bin)/webpack -p --config browser_client/config/webpack.build.js", 9 | "check": "npm run lint && npm run test:all", 10 | "lint": "$(npm bin)/eslint browser_client/", 11 | "lint:fix": "npm run lint -- --fix", 12 | "test": "$(npm bin)/karma start browser_client/config/karma.unit.js --single-run", 13 | "test:watch": "$(npm bin)/karma start browser_client/config/karma.unit.js", 14 | "test:coverage": "$(npm bin)/karma start browser_client/config/karma.coverage.js --single-run", 15 | "test:e2e": "$(npm bin)/karma start browser_client/config/karma.e2e.js --single-run", 16 | "test:all": "$(npm bin)/karma start browser_client/config/karma.all.js --single-run" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/py-in-the-sky/gae-flask-redux-react-starter-kit.git" 21 | }, 22 | "keywords": [ 23 | "redux", 24 | "redux-devtools", 25 | "react", 26 | "velocity-react", 27 | "react-router", 28 | "immutable", 29 | "webpack", 30 | "webpack-dev-server", 31 | "babel" 32 | ], 33 | "author": "Ryan Wise", 34 | "license": "MIT", 35 | "bugs": { 36 | "url": "https://github.com/py-in-the-sky/gae-flask-redux-react-starter-kit/issues" 37 | }, 38 | "homepage": "https://github.com/py-in-the-sky/gae-flask-redux-react-starter-kit#readme", 39 | "dependencies": { 40 | "formsy-material-ui": "^0.3.7", 41 | "formsy-react": "^0.17.0", 42 | "history": "^1.17.0", 43 | "humps": "^1.0.0", 44 | "immutable": "^3.7.6", 45 | "jquery": "^2.1.4", 46 | "jsxstyle": "0.0.18", 47 | "lodash": "^3.10.1", 48 | "material-ui": "^0.13.2", 49 | "react": "^0.14.6", 50 | "react-addons-create-fragment": "^0.14.6", 51 | "react-addons-pure-render-mixin": "^0.14.6", 52 | "react-addons-transition-group": "^0.14.6", 53 | "react-addons-update": "^0.14.6", 54 | "react-dom": "^0.14.6", 55 | "react-immutable-proptypes": "^1.5.1", 56 | "react-motion": "^0.3.1", 57 | "react-pure-render": "^1.0.2", 58 | "react-redux": "^4.0.6", 59 | "react-router": "^1.0.3", 60 | "react-tap-event-plugin": "^0.2.1", 61 | "recompose": "^0.14.5", 62 | "redux": "^3.0.5", 63 | "redux-actions": "^0.9.0", 64 | "redux-thunk": "^1.0.3", 65 | "velocity-animate": "^1.2.3", 66 | "velocity-react": "twitter-fabric/velocity-react" 67 | }, 68 | "devDependencies": { 69 | "babel-cli": "^6.4.5", 70 | "babel-core": "^6.4.5", 71 | "babel-eslint": "^4.1.6", 72 | "babel-loader": "^6.2.1", 73 | "babel-plugin-add-module-exports": "^0.1.2", 74 | "babel-plugin-react-transform": "^2.0.0", 75 | "babel-plugin-transform-react-constant-elements": "^6.4.0", 76 | "babel-plugin-transform-react-inline-elements": "^6.4.0", 77 | "babel-plugin-transform-remove-debugger": "^6.3.13", 78 | "babel-plugin-transform-runtime": "^6.4.3", 79 | "babel-preset-es2015": "^6.3.13", 80 | "babel-preset-react": "^6.3.13", 81 | "babel-preset-stage-2": "^6.3.13", 82 | "babel-runtime": "^6.3.19", 83 | "chai": "^3.4.1", 84 | "chai-enzyme": "^0.2.2", 85 | "cheerio": "^0.19.0", 86 | "deep-freeze-node": "^1.1.1", 87 | "empty": "^0.10.1", 88 | "enzyme": "^1.3.1", 89 | "eslint": "^1.9.0", 90 | "eslint-config-standard": "^4.4.0", 91 | "eslint-config-standard-react": "^1.2.1", 92 | "eslint-plugin-react": "^3.14.0", 93 | "eslint-plugin-standard": "^1.3.1", 94 | "fs-extra": "^0.26.4", 95 | "html-webpack-plugin": "^1.6.2", 96 | "isparta-instrumenter-loader": "^1.0.0", 97 | "json-loader": "^0.5.4", 98 | "jsverify": "^0.7.1", 99 | "karma": "^0.13.19", 100 | "karma-chai": "^0.1.0", 101 | "karma-chrome-launcher": "^0.2.1", 102 | "karma-coverage": "^0.5.3", 103 | "karma-mocha": "^0.2.0", 104 | "karma-mocha-reporter": "^1.1.5", 105 | "karma-sinon": "^1.0.4", 106 | "karma-sourcemap-loader": "^0.3.6", 107 | "karma-spec-reporter": "0.0.22", 108 | "karma-webpack": "^1.7.0", 109 | "mocha": "^2.3.3", 110 | "react-addons-perf": "^0.14.6", 111 | "react-addons-test-utils": "^0.14.6", 112 | "react-transform-catch-errors": "^1.0.1", 113 | "react-transform-hmr": "^1.0.1", 114 | "react-transform-render-visualizer": "^0.3.0", 115 | "redbox-react": "^1.2.0", 116 | "redux-devtools": "^2.1.5", 117 | "redux-mock-store": "0.0.6", 118 | "sinon": "^1.17.2", 119 | "sinon-chai": "^2.8.0", 120 | "socket.io-client": "1.3.7", 121 | "stats-webpack-plugin": "^0.3.0", 122 | "webpack": "^1.12.10", 123 | "webpack-dev-server": "1.12.1" 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /browser_client/__test__/components/AddNameForm-test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import { mount } from 'enzyme' 4 | import { 5 | Simulate, 6 | findRenderedDOMComponentWithTag, 7 | } from 'react-addons-test-utils' 8 | import { deepFreeze } from 'app/utils/deepFreeze' 9 | import { Form } from 'formsy-react' 10 | import AddNameForm from 'app/components/AddNameForm' 11 | 12 | 13 | describe('user interaction', () => { 14 | const setup = (serverValidation = {}) => { 15 | const props = deepFreeze({ 16 | addName: sinon.spy(), 17 | clearServerValidation: sinon.spy(), 18 | serverValidation, 19 | }) 20 | 21 | const wrapper = mount() 22 | const input = wrapper.find('input').first() 23 | const button = wrapper.find('button').first() 24 | const formsy = wrapper.find(Form) 25 | 26 | return { props, wrapper, input, button, formsy } 27 | } 28 | 29 | const fillInText = (input, text) => { 30 | input.value = text 31 | Simulate.change(input) 32 | } 33 | 34 | const submitForm = component => { 35 | Simulate.submit(findRenderedDOMComponentWithTag(component, 'form')) 36 | } 37 | 38 | it('enables the submit button when a name is filled in', () => { 39 | const { input, button } = setup() 40 | fillInText(input.node, 'hi') 41 | expect( button ).to.not.be.disabled() 42 | }) 43 | 44 | it('enables the submit button when a name is filled in (alt)', () => { 45 | // same test as above, just a different way to simulate user input 46 | const { formsy, button } = setup() 47 | formsy.node.inputs.name.setValue('blah') 48 | expect( button ).to.not.be.disabled() 49 | }) 50 | 51 | it('disables the submit button when no name is filled in', () => { 52 | const { button, input } = setup() 53 | expect( button ).to.be.disabled() 54 | fillInText(input.node, '') 55 | expect( button ).to.be.disabled() 56 | }) 57 | 58 | it('disables the submit button when the name is too long', () => { 59 | const { button, formsy } = setup() 60 | const nameInput = formsy.node.inputs.name 61 | 62 | nameInput.setValue('hi') 63 | expect( button ).to.not.be.disabled() 64 | 65 | nameInput.setValue('toooooooloooooong') 66 | expect( button ).to.be.disabled() 67 | }) 68 | 69 | it('disables the submit button when the name is not just letters', () => { 70 | const { input, button } = setup() 71 | fillInText(input.node, 'abc 123') 72 | expect( button ).to.be.disabled() 73 | }) 74 | 75 | it('calls `addName` when the form is submitted', () => { 76 | const { input, formsy, props } = setup() 77 | 78 | expect( props.addName ).to.not.have.been.called 79 | 80 | fillInText(input.node, 'hi') 81 | submitForm(formsy.node) 82 | 83 | expect( props.addName ).to.have.been.calledWith( 'hi' ) 84 | }) 85 | 86 | context('when server validation errors are present', () => { 87 | it('calls `clearServerValidation` when the user changes `name`', () => { 88 | const { input, props } = setup({ message: { name: 'error' } }) 89 | expect( props.clearServerValidation ).to.not.have.been.called 90 | fillInText(input.node, 'hi') 91 | expect( props.clearServerValidation ).to.have.been.called 92 | }) 93 | }) 94 | 95 | context('when server validation errors are not present', () => { 96 | it('does not call `clearServerValidation` when the user changes `name`', () => { 97 | const { input, props } = setup() 98 | fillInText(input.node, 'hi') 99 | expect( props.clearServerValidation ).to.not.have.been.called 100 | }) 101 | }) 102 | 103 | context('when the component unmounts', () => { 104 | const setup = (serverValidation = {}) => { 105 | const props = deepFreeze({ 106 | addName: sinon.spy(), 107 | clearServerValidation: sinon.spy(), 108 | serverValidation, 109 | }) 110 | 111 | // thanks to: http://stackoverflow.com/a/23974520/1941513 112 | const container = document.createElement('div') 113 | ReactDOM.render(, container) 114 | 115 | expect( props.clearServerValidation ).to.not.have.been.called 116 | 117 | ReactDOM.unmountComponentAtNode(container) 118 | 119 | return props.clearServerValidation 120 | } 121 | 122 | it('calls `clearServerValidation` if server validations are present', () => { 123 | const clearServerValidation = setup({ message: { name: 'error' } }) 124 | expect( clearServerValidation ).to.have.been.called 125 | }) 126 | 127 | it('does not call `clearServerValidation` if server validations are not present', () => { 128 | const clearServerValidation = setup() 129 | expect( clearServerValidation ).to.not.have.been.called 130 | }) 131 | }) 132 | }) 133 | -------------------------------------------------------------------------------- /browser_client/src/app/components/Flex/Flex.js: -------------------------------------------------------------------------------- 1 | /* 2 | NB: if children of a flexbox also have 'display: flex', then those 3 | children will have the same height by default. 4 | 5 | NB: for parent-child spacing, prefer padding on the parent. 6 | 7 | Concept: Think of parent components as composing rectangles, one 8 | for each child. Each child itself will decide what colors and 9 | shapes to present within its given rectangle. (And then animations 10 | will be responsible for making relative changes to the rectangles 11 | as well as to the children within the rectangles.) So the concerns 12 | of styling are separated such that parents are responsible for layout 13 | (rectangles) and spacing (margin/padding on the rectangles) and 14 | children are responsible for other styles, given their layout container 15 | (i.e., rectangle). 16 | 17 | Layout/container/flex system: 18 | - at the highest level, the root layout component will be a Flex 19 | component (WindowFrame) that spans the height and width of the 20 | viewport 21 | 22 | - the root layout component can have a minHeight 23 | 24 | - some parent components will react to window-size data from the 25 | store and make relevant layout decisions (similar to media queries) 26 | 27 | - parent/container component is coded to declare styles on itself 28 | and children rectangles in order to establish the following properties 29 | of children: 30 | 31 | * size: their sizes will be a fraction of the parent's width/height 32 | and possibly further refined with sizes relative to each other via 33 | flex attributes 34 | * layout: which children are present and in which order; which children 35 | appear in the same column or row and which in different ones; whether 36 | children are distributed vertically and horizontally 37 | * spacing: the minimum amount of space on all four sides a child requires 38 | between itself and peers; the minimum space required between the border 39 | of the parent itself and its children rectangles; for any intermediate 40 | containers (rows, columns, etc.), the minimum required space between 41 | the container's border and its immediate children rectangles. 42 | * z-index/z-height (think material design) 43 | 44 | styling borrowed from: 45 | https://github.com/kristoferjoseph/flexboxgrid/blob/master/src/css/flexboxgrid.css 46 | https://philipwalton.github.io/solved-by-flexbox/demos/grids/ 47 | http://foundation.zurb.com/apps/docs/#!/grid 48 | https://css-tricks.com/snippets/css/a-guide-to-flexbox/ 49 | https://css-tricks.com/useful-flexbox-technique-alignment-shifting-wrapping/ 50 | https://css-tricks.com/box-sizing/ 51 | http://learnlayout.com/box-sizing.html 52 | https://css-tricks.com/the-lengths-of-css/ 53 | http://stackoverflow.com/a/1655398/1941513 54 | 55 | also see this flexbox-based grid system that uses React with inline sytles: 56 | http://broucz.github.io/react-inline-grid/ 57 | https://github.com/broucz/react-inline-grid 58 | 59 | flexbox playgrounds: 60 | http://demo.agektmr.com/flexbox/ 61 | http://bennettfeely.com/flexplorer/ 62 | http://the-echoplex.net/flexyboxes/ 63 | */ 64 | 65 | 66 | import BaseFlex from 'jsxstyle/Flex' 67 | import curry from 'jsxstyle/curry' 68 | 69 | 70 | export const Flex = curry(BaseFlex, { 71 | backfaceVisibility: 'hidden', 72 | boxSizing: 'border-box', 73 | flex: '1 1 auto', 74 | height: 'auto', 75 | justifyContent: 'flex-start', 76 | order: 0, 77 | position: 'relative', 78 | }) 79 | 80 | 81 | export const ColumnWise = curry(Flex, { 82 | flexDirection: 'column', 83 | }) 84 | 85 | 86 | export const Column = curry(Flex, { 87 | flexDirection: 'column', 88 | alignItems: 'stretch', 89 | }) 90 | 91 | 92 | export const RowWise = curry(Flex, { 93 | flexDirection: 'row', 94 | }) 95 | 96 | 97 | export const Row = curry(Flex, { 98 | flexDirection: 'row', 99 | alignItems: 'stretch', 100 | }) 101 | 102 | 103 | export const Container = curry(Flex, { 104 | alignItems: 'stretch', 105 | flexDirection: 'row', 106 | flexWrap: 'nowrap', 107 | overflow: 'hidden', 108 | padding: '1rem', 109 | }) 110 | 111 | 112 | /* 113 | Fits width and height of the viewport. Use as the root flexbox 114 | container in the app. Default: it arranges children in a 115 | column-wise orientation. 116 | */ 117 | export const WindowFrame = curry(Flex, { 118 | alignItems: 'stretch', 119 | flexDirection: 'column', 120 | flexWrap: 'nowrap', 121 | height: '100vh', 122 | minHeight: '400px', 123 | overflowX: 'hidden', 124 | overflowY: 'auto', 125 | }) 126 | 127 | 128 | export const Frame = curry(Container, { 129 | minHeight: '100%', 130 | justifyContent: 'center', 131 | }) 132 | 133 | 134 | /* 135 | Default: when `flexDirection` is `row`, it's just tall enough 136 | to accommodate children. 137 | Alternative: when `flexDirection` is overridden and set to 138 | `column`, it's just wide enough to accommodate children. 139 | Optionally add padding to give some space between the border 140 | of ShrinkWrap and its children. 141 | This container is useful for menu bars. 142 | NB: if you include drop-down menus, make sure to not set 143 | `overflow` to `hidden` and make sure to keep `position` as 144 | `relative` and the `position` of the drop-down menu as 145 | `absolute`. 146 | */ 147 | export const ShrinkWrap = curry(Flex, { 148 | alignItems: 'stretch', 149 | flex: '0 0 auto', 150 | flexDirection: 'row', 151 | flexWrap: 'wrap', 152 | }) 153 | -------------------------------------------------------------------------------- /browser_client/__test__/middleware/api-test.js: -------------------------------------------------------------------------------- 1 | import apiMiddleware, { 2 | beforeFetch, 3 | onFetchFail, 4 | onNetworkError, 5 | } from 'app/middleware/api' 6 | import { ActionCreators, ActionTypes as T } from 'app/actions' 7 | import configureMockStore from 'redux-mock-store' 8 | import { deepFreeze } from 'app/utils/deepFreeze' 9 | 10 | 11 | describe('`beforeFetch`', () => { 12 | it('returns a fetch call with added headers and stringified body', () => { 13 | const fetchCall = { 14 | endpoint: '/blah/', 15 | method: 'POST', 16 | body: { hello: 'world' }, 17 | } 18 | 19 | expect( beforeFetch(fetchCall) ).to.deep.eq({ 20 | endpoint: '/blah/', 21 | method: 'POST', 22 | body: '{"hello":"world"}', 23 | credentials: 'same-origin', 24 | headers: { 25 | 'Accept': 'application/json', 26 | 'Content-Type': 'application/json', 27 | }, 28 | }) 29 | }) 30 | 31 | it('leaves `body` undefined if not provided', () => { 32 | const fetchCall = { 33 | endpoint: '/blah/', 34 | method: 'GET', 35 | } 36 | 37 | expect( beforeFetch(fetchCall).body ).to.be.undefined 38 | }) 39 | }) 40 | 41 | 42 | describe('`onFetchFail`', () => { 43 | const setup = status => ({ 44 | dispatch: sinon.spy(), 45 | response: { status }, 46 | body: 'body', 47 | }) 48 | 49 | it('returns a body-response pair', () => { 50 | const { body, response } = setup(400) 51 | expect( onFetchFail(body, response) ).to.deep.eq( [body, response] ) 52 | }) 53 | 54 | context('when the response represents a 500-level error', () => { 55 | it('returns an undefined-response pair', () => { 56 | const { body, response, dispatch } = setup(500) 57 | expect( onFetchFail(body, response, undefined, undefined, dispatch) ) 58 | .to.deep.eq( [undefined, response] ) 59 | }) 60 | 61 | it('dispatches the SERVER_ERROR action to the store', () => { 62 | const { body, response, dispatch } = setup(500) 63 | onFetchFail(body, response, undefined, undefined, dispatch) 64 | expect( dispatch ).to.have.been.calledWithMatch({ type: T.SERVER_ERROR }) 65 | }) 66 | }) 67 | }) 68 | 69 | 70 | describe('`onNetworkError`', () => { 71 | it('returns an empty array', () => { 72 | expect( onNetworkError(undefined, undefined, undefined, sinon.spy()) ) 73 | .to.deep.eq( [] ) 74 | }) 75 | 76 | it('dispatches the NETWORK_ERROR action to the store', () => { 77 | const dispatch = sinon.spy() 78 | onNetworkError(undefined, undefined, undefined, dispatch) 79 | expect( dispatch ).to.have.been.calledWithMatch({ type: T.NETWORK_ERROR }) 80 | }) 81 | }) 82 | 83 | 84 | describe('redux integration', () => { 85 | afterEach(() => { 86 | if (window.fetch.restore) window.fetch.restore() 87 | }) 88 | 89 | const makeResponse = (status = 200) => new window.Response( 90 | '{"name":"foo"}', 91 | { status, headers: { 'Content-type': 'application/json' } } 92 | ) 93 | 94 | const setup = (response, error = false) => { 95 | if (error) { 96 | sinon.stub(window, 'fetch', () => Promise.reject('error')) 97 | } else { 98 | sinon.stub(window, 'fetch', 99 | () => Promise.resolve(response || makeResponse())) 100 | } 101 | 102 | return configureMockStore([ apiMiddleware ]) 103 | } 104 | 105 | it('dispatches the expected actions when successful', done => { 106 | const mockStore = setup() 107 | 108 | let requestId 109 | 110 | const expectedActions = [ 111 | firstAction => { 112 | expect( firstAction.type ).to.equal( T.ADD_NAME ) 113 | expect( firstAction.meta ).to.have.all.keys( 'requestId' ) 114 | 115 | requestId = firstAction.meta.requestId 116 | }, 117 | secondAction => { 118 | expect( secondAction.type ).to.equal( T.ADD_NAME_DONE ) 119 | expect( secondAction.payload ).to.deep.equal( { name: 'foo' } ) 120 | expect( secondAction.meta ).to.have.all.keys( 'requestId' ) 121 | 122 | expect( secondAction.meta.requestId ).to.equal( requestId ) 123 | }, 124 | ] 125 | 126 | const store = mockStore(deepFreeze({}), expectedActions, done) 127 | store.dispatch(ActionCreators.addName()) 128 | }) 129 | 130 | it('dispatches the expected actions when there is an error', done => { 131 | const mockStore = setup(makeResponse(400)) 132 | 133 | let requestId 134 | 135 | const expectedActions = [ 136 | firstAction => { 137 | expect( firstAction.type ).to.equal( T.ADD_NAME ) 138 | expect( firstAction.meta ).to.have.all.keys( 'requestId' ) 139 | 140 | requestId = firstAction.meta.requestId 141 | }, 142 | secondAction => { 143 | expect( secondAction.type ).to.equal( T.ADD_NAME_FAIL ) 144 | expect( secondAction.meta ).to.have.all.keys( 'requestId' ) 145 | 146 | expect( secondAction.meta.requestId ).to.equal( requestId ) 147 | }, 148 | ] 149 | 150 | const store = mockStore(deepFreeze({}), expectedActions, done) 151 | store.dispatch(ActionCreators.addName()) 152 | }) 153 | 154 | it('dispatches `SERVER_ERROR` when there is a 500 error', done => { 155 | const mockStore = setup(makeResponse(500)) 156 | 157 | const expectedActions = [ 158 | firstAction => { 159 | expect( firstAction.type ).to.equal( T.ADD_NAME ) 160 | }, 161 | secondAction => { 162 | expect( secondAction.type ).to.equal( T.SERVER_ERROR ) 163 | }, 164 | thirdAction => { 165 | expect( thirdAction.type ).to.equal( T.ADD_NAME_FAIL ) 166 | }, 167 | ] 168 | 169 | const store = mockStore(deepFreeze({}), expectedActions, done) 170 | store.dispatch(ActionCreators.addName()) 171 | }) 172 | 173 | it('dispatches `NETWORK_ERROR` when fetch resolves to an error state', done => { 174 | const error = true 175 | const mockStore = setup(undefined, error) 176 | 177 | const expectedActions = [ 178 | firstAction => { 179 | expect( firstAction.type ).to.equal( T.ADD_NAME ) 180 | }, 181 | secondAction => { 182 | expect( secondAction.type ).to.equal( T.NETWORK_ERROR ) 183 | }, 184 | thirdAction => { 185 | expect( thirdAction.type ).to.equal( T.ADD_NAME_FAIL ) 186 | }, 187 | ] 188 | 189 | const store = mockStore(deepFreeze({}), expectedActions, done) 190 | store.dispatch(ActionCreators.addName()) 191 | }) 192 | }) 193 | -------------------------------------------------------------------------------- /browser_client/config/webpack.make.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-var */ 2 | /* eslint-disable prefer-template */ 3 | /* eslint-disable no-console */ 4 | 5 | 6 | var path = require('path') 7 | var webpack = require('webpack') 8 | const assign = require('lodash').assign 9 | 10 | 11 | var devPublicPath = 'http://localhost:8081/static/' 12 | // Trailing slash is critical. 13 | // The JS, CSS, etc. serverd by the webpack dev server will 14 | // be available at publicPath. 15 | // Conceptually, devPublicPath will be your local dev CDN for 16 | // your own JS, CSS, etc. bundles. 17 | 18 | var context = path.join(__dirname, '..', '..') 19 | 20 | 21 | var configDefaults = { 22 | context: context, 23 | entry: path.join(context, 'browser_client', 'src', 'index.js'), 24 | output: { 25 | filename: '[name].js', 26 | path: path.join(context, 'gae', 'static'), 27 | publicPath: '/static', 28 | }, 29 | resolve: { 30 | root: path.join(context, 'browser_client', 'src'), 31 | extensions: ['', '.js'], 32 | }, 33 | // externals: { jquery: '$' }, 34 | } 35 | 36 | 37 | var defautlBabelPresets = [ 'react', 'es2015', 'stage-2' ] 38 | var defaultBabelPlugins = [ 'add-module-exports' ] 39 | 40 | 41 | var codeCoveragePreloader = { 42 | preLoaders: [ 43 | { 44 | test: /\.js$/, 45 | include: /browser_client\/src/, 46 | loader: 'isparta-instrumenter', 47 | query: { 48 | babel: { 49 | presets: defautlBabelPresets.concat(), 50 | plugins: defaultBabelPlugins.concat(), 51 | }, 52 | }, 53 | } 54 | ] 55 | } 56 | 57 | 58 | module.exports = function makeWebpackConfig (opts) { 59 | var __DEV__ = Boolean(opts.__DEV__) 60 | var __TEST__ = Boolean(opts.__TEST__) 61 | var __COVERAGE__ = Boolean(opts.__COVERAGE__) 62 | 63 | var nodeEnv 64 | if (__DEV__) nodeEnv = 'development' 65 | else if (__TEST__) nodeEnv = 'test' 66 | else nodeEnv = 'production' 67 | 68 | var EnvironmentPlugin = new webpack.DefinePlugin({ 69 | __DEV__: __DEV__, 70 | __TEST__: __TEST__, 71 | 'process.env': { NODE_ENV: JSON.stringify(nodeEnv) }, 72 | // Set process.env.NODE_ENV to 'production' when not in dev mode. 73 | // Must use this plugin and not an environment variable. 74 | // Will optimize react build in production, including skipping 75 | // proptype checks. 76 | // https://github.com/webpack/react-starter/blob/master/make-webpack-config.js#L131 77 | // https://facebook.github.io/react/downloads.html#npm 78 | // https://github.com/facebook/react/issues/2938 79 | }) 80 | 81 | var babelPresets = defautlBabelPresets.concat() 82 | var babelPlugins = defaultBabelPlugins.concat() 83 | if (__DEV__) { 84 | var devReactTransforms = [ 85 | { 86 | 'transform': 'react-transform-hmr', 87 | 'imports': [ 'react' ], 88 | 'locals': [ 'module' ], 89 | }, { 90 | 'transform': 'react-transform-catch-errors', 91 | 'imports': [ 'react', 'redbox-react' ], 92 | }, 93 | ] 94 | 95 | if (process.env.hasOwnProperty('REACT_VIS')) { 96 | devReactTransforms = devReactTransforms.concat({ 97 | 'transform': 'react-transform-render-visualizer', 98 | }) 99 | } else { 100 | console.log('****************************************************') 101 | console.log('*** To enable visualization of React re-renders, ***') 102 | console.log('*** run this process with the `REACT_VIS=true` ***') 103 | console.log('*** environment variable. ***') 104 | console.log('****************************************************') 105 | } 106 | 107 | babelPlugins = babelPlugins.concat([[ 108 | 'react-transform', { 'transforms': devReactTransforms } 109 | ]]) 110 | } else if (__TEST__) { 111 | null 112 | // babelPlugins = babelPlugins.concat('transform-runtime') 113 | // This plugin will cause test failures right now because 114 | // the core-js implementation of `Object.assign` does not 115 | // use strict mode and so will silently fail when trying 116 | // to mutate frozen data. 117 | } else { // production 118 | babelPlugins = babelPlugins.concat( 119 | // 'transform-runtime', 120 | // Can use transform-runtime and the underlying core-js 121 | // polyfill that this plugin provides after its `Object.assign` 122 | // is run in strict mode, which will make it 123 | // thow an error on mutation of frozen data. 124 | // (core-js >= 2.0.3. Right now, Babel uses v1.2.6.) 125 | // see 126 | // https://github.com/zloirock/core-js/issues/154 127 | // https://github.com/zloirock/core-js/commit/7bf885b119b1db54154c40a3fcf327cf2438e016 128 | 'transform-remove-debugger', 129 | 'transform-react-inline-elements', 130 | 'transform-react-constant-elements' 131 | ) 132 | } 133 | 134 | configDefaults['module'] = { 135 | loaders: [ 136 | { 137 | test: /\.js$/, 138 | include: __TEST__ 139 | ? [ /browser_client\/src/, /browser_client\/__test__/ ] 140 | : [ /browser_client\/src/ ], 141 | exclude: /node_modules/, 142 | loader: 'babel-loader', 143 | query: { 144 | cacheDirectory: true, 145 | presets: babelPresets, 146 | plugins: babelPlugins, 147 | }, 148 | }, 149 | ], 150 | } 151 | 152 | if (__DEV__) { 153 | // SIDE EFFECT: copy `index.dev.html` to `gae/static/index.html` 154 | var fs = require('fs-extra') 155 | var htmlTemplate = path.join('browser_client', 'html', 'index.dev.html') 156 | var htmlTarget = path.join('gae', 'static', 'index.html') 157 | fs.ensureFileSync(htmlTarget) 158 | fs.copy(htmlTemplate, htmlTarget, { clobber: true }) 159 | 160 | return assign({}, configDefaults, { 161 | debug: true, 162 | displayErrorDetails: true, 163 | outputPathinfo: true, 164 | devtool: 'eval', 165 | entry: [ 166 | 'webpack-dev-server/client?' + devPublicPath, // for non-hot updates 167 | 'webpack/hot/only-dev-server', // for hot updates 168 | configDefaults.entry 169 | ], 170 | output: assign({}, configDefaults.output, { 171 | publicPath: devPublicPath, 172 | devtoolModuleFilenameTemplate: '[resourcePath]', 173 | devtoolFallbackModuleFilenameTemplate: '[resourcePath]?[hash]' 174 | }), 175 | plugins: [ EnvironmentPlugin ], 176 | }) 177 | } else if (__TEST__) { 178 | var preLoaders = __COVERAGE__ ? codeCoveragePreloader : {} 179 | 180 | return { 181 | devtool: 'inline-source-map', 182 | resolve: assign({}, configDefaults.resolve, { 183 | alias: { 'sinon': 'sinon/pkg/sinon' }, // NB: config for `enzyme` 184 | // for getting `enzyme` up and running with `webpack` and `karma`, see: 185 | // https://github.com/davezuko/react-redux-starter-kit/issues/328 186 | // https://github.com/airbnb/enzyme/issues/47 187 | // http://spencerdixon.com/blog/test-driven-react-tutorial.html 188 | }), 189 | externals: { // NB: config for `enzyme` 190 | 'jsdom': 'window', 191 | 'cheerio': 'window', 192 | 'react/lib/ExecutionEnvironment': true, 193 | 'react/lib/ReactContext': 'window', 194 | }, 195 | plugins: [ EnvironmentPlugin ], 196 | module: assign({}, preLoaders, { 197 | loaders: configDefaults.module.loaders.concat({ 198 | // NB: config for `enzyme` 199 | test: /\.json$/, 200 | loader: 'json', 201 | }), 202 | noParse: [ // NB: config for `enzyme` 203 | /node_modules\/sinon\//, 204 | ], 205 | }), 206 | } 207 | } else { 208 | var HtmlWebpackPlugin = require('html-webpack-plugin') 209 | var StatsPlugin = require('stats-webpack-plugin') 210 | 211 | return assign({}, configDefaults, { 212 | plugins: [ 213 | EnvironmentPlugin, 214 | new HtmlWebpackPlugin({ 215 | template: path.join('browser_client', 'html', 'index.prod.html'), 216 | // favicon: '', 217 | inject: 'body', // Inject all scripts into the body 218 | hash: true, 219 | minify: { 220 | collapseWhitespace: true, 221 | minifyJS: true, 222 | minifyCSS: true 223 | }, 224 | }), 225 | new webpack.optimize.DedupePlugin(), 226 | new webpack.optimize.UglifyJsPlugin(), 227 | new webpack.optimize.OccurenceOrderPlugin(), 228 | new StatsPlugin(path.join('..', '..', '__stats__', 'webpack.json'), { 229 | chunkModules: true, 230 | exclude: [/node_modules[\\\/]react/], 231 | }), 232 | ], 233 | }) 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /browser_client/__test__/middleware/fetch-test.js: -------------------------------------------------------------------------------- 1 | import fetchMiddleware from 'app/middleware/fetch' 2 | import { deepFreeze } from 'app/utils/deepFreeze' 3 | 4 | 5 | const MY_FETCH_KEY = Symbol('my-fetch-key') 6 | 7 | 8 | describe('fetchMiddleware', () => { 9 | it('is a constructor that returns a middleware function', () => { 10 | expect( fetchMiddleware() ).to.be.a( 'function' ) 11 | }) 12 | 13 | it('expects the sole options argument to be an object or undefined', () => { 14 | expect( () => fetchMiddleware('String is not the right type') ) 15 | .to.throw( Error ) 16 | }) 17 | 18 | it('validates option entries', () => { 19 | expect( () => fetchMiddleware({ key: 'String is not the right type' }) ) 20 | .to.throw( Error ) 21 | 22 | expect( () => fetchMiddleware({ beforeFetch: 'String is not the right type' }) ) 23 | .to.throw( Error ) 24 | 25 | expect( () => fetchMiddleware({ beforeFetch: () => 1 + 1 }) ) 26 | .to.not.throw( Error ) 27 | }) 28 | }) 29 | 30 | 31 | describe('the middleware returned by `fetchMiddleware`', () => { 32 | afterEach(() => { 33 | if (window.fetch.restore) window.fetch.restore() 34 | }) 35 | 36 | const makeResponse = (status = 200) => new window.Response( 37 | '{"hello":"world"}', 38 | { status, headers: { 'Content-type': 'application/json' } } 39 | ) 40 | 41 | const makeAction = (frozen = true) => { 42 | const maybeFreeze = frozen ? deepFreeze : x => x 43 | 44 | return maybeFreeze({ 45 | type: 'TYPE', 46 | meta: { 47 | [MY_FETCH_KEY]: maybeFreeze({ 48 | endpoint: '/test', 49 | method: 'POST', 50 | body: 'body', 51 | done: sinon.spy( x => x ), 52 | fail: sinon.spy( x => x ), 53 | }), 54 | }, 55 | }) 56 | } 57 | 58 | const setup = (response, error = false) => { 59 | let res 60 | 61 | if (error) { 62 | sinon.stub(window, 'fetch', () => Promise.reject('error')) 63 | } else { 64 | res = response || makeResponse() 65 | sinon.stub(window, 'fetch', () => Promise.resolve(res)) 66 | } 67 | 68 | const opts = { 69 | key: MY_FETCH_KEY, 70 | beforeFetch: sinon.spy( x => x ), 71 | onFetchDone: sinon.spy( (...args) => args ), 72 | onFetchFail: sinon.spy( (...args) => args ), 73 | onNetworkError: sinon.spy( (...args) => args ), 74 | } 75 | 76 | const store = { 77 | dispatch: sinon.spy(), 78 | getState: sinon.spy(), 79 | } 80 | 81 | const nextMiddleware = sinon.spy() 82 | const middleware = fetchMiddleware(opts)(store)(nextMiddleware) 83 | const action = makeAction() 84 | 85 | return { response: res, opts, store, nextMiddleware, middleware, action } 86 | } 87 | 88 | context('when a fetch call is not requested', () => { 89 | it('calls the next middleware with the action and returns', () => { 90 | const { opts, store, nextMiddleware, middleware } = setup() 91 | const action = deepFreeze({ type: 'TYPE' }) 92 | middleware(action) 93 | 94 | expect( nextMiddleware ).to.have.been.calledWith( action ) 95 | expect( store.dispatch ).to.not.have.been.called 96 | expect( opts.beforeFetch ).to.not.have.been.called 97 | }) 98 | }) 99 | 100 | it('calls `beforeFetch` with the expected arguments', () => { 101 | const { opts, middleware, action, store: { dispatch, getState } } = setup() 102 | const call = action.meta[MY_FETCH_KEY] 103 | middleware(action) 104 | 105 | expect( opts.beforeFetch ) 106 | .to.have.been.calledWithExactly( call, action, dispatch, getState ) 107 | }) 108 | 109 | context('when `dispatchBaseAction` is `false`', () => { 110 | it('does not call the next middleware with the given action', () => { 111 | const { nextMiddleware, middleware } = setup() 112 | const action = makeAction(false) 113 | action.meta[MY_FETCH_KEY].dispatchBaseAction = false 114 | middleware(action) 115 | 116 | expect( nextMiddleware ).to.not.have.been.called 117 | }) 118 | }) 119 | 120 | context('when `dispatchBaseAction` is not `false`', () => { 121 | it('calls the next middleware with the given action', () => { 122 | const { nextMiddleware, middleware, action } = setup() 123 | middleware(action) 124 | 125 | expect( nextMiddleware ).to.have.been.calledWithExactly( action ) 126 | }) 127 | }) 128 | 129 | it('calls `fetch` with the expected arguments', () => { 130 | const { middleware, action } = setup() 131 | middleware(action) 132 | const { endpoint, method, body } = action.meta[MY_FETCH_KEY] 133 | 134 | expect( fetch ) 135 | .to.have.been.calledWithMatch( endpoint, { method, body } ) 136 | }) 137 | 138 | context('when the fetch request is successful', () => { 139 | it('calls `onFetchDone` with the expected arguments', done => { 140 | const { 141 | opts, 142 | middleware, 143 | action, 144 | response, 145 | store: { dispatch, getState } 146 | } = setup() 147 | 148 | const fetchCall = action.meta[MY_FETCH_KEY] 149 | 150 | middleware(action).then( 151 | () => { 152 | expect( opts.onFetchFail ).to.not.have.been.called 153 | 154 | expect( opts.onFetchDone ) 155 | .to.have.been 156 | .calledWith( { hello: 'world' }, response, fetchCall, action, dispatch, getState ) 157 | 158 | done() 159 | } 160 | ) 161 | }) 162 | 163 | it('calls `done` with the expected arguments', done => { 164 | const { opts, middleware, action } = setup() 165 | const fetchCall = action.meta[MY_FETCH_KEY] 166 | 167 | middleware(action).then( 168 | () => { 169 | expect( fetchCall.done ) 170 | .to.have.been 171 | .calledWith( ...opts.onFetchDone.lastCall.returnValue ) 172 | 173 | done() 174 | } 175 | ) 176 | }) 177 | 178 | it('dispatches the "done" action through the full middleware chain', done => { 179 | const { middleware, action, store } = setup() 180 | const fetchCall = action.meta[MY_FETCH_KEY] 181 | 182 | middleware(action).then( 183 | () => { 184 | expect( store.dispatch ) 185 | .to.have.been 186 | .calledWith( fetchCall.done.lastCall.returnValue ) 187 | 188 | done() 189 | } 190 | ) 191 | }) 192 | }) 193 | 194 | context('when the fetch request results in a 400-level error', () => { 195 | it('calls `onFetchFail` with the expected arguments', done => { 196 | const { 197 | opts, 198 | middleware, 199 | action, 200 | response, 201 | store: { dispatch, getState } 202 | } = setup(makeResponse(400)) 203 | 204 | const fetchCall = action.meta[MY_FETCH_KEY] 205 | 206 | middleware(action).then( 207 | () => { 208 | expect( opts.onFetchDone ).to.not.have.been.called 209 | 210 | expect( opts.onFetchFail ) 211 | .to.have.been 212 | .calledWith( { hello: 'world' }, response, fetchCall, action, dispatch, getState ) 213 | 214 | done() 215 | } 216 | ) 217 | }) 218 | 219 | it('calls `fail` with the expected arguments', done => { 220 | const { opts, middleware, action } = setup(makeResponse(400)) 221 | const fetchCall = action.meta[MY_FETCH_KEY] 222 | 223 | middleware(action).then( 224 | () => { 225 | expect( fetchCall.fail ) 226 | .to.have.been 227 | .calledWith( ...opts.onFetchFail.lastCall.returnValue ) 228 | 229 | done() 230 | } 231 | ) 232 | }) 233 | 234 | it('dispatches the "fail" action through the full middleware chain', done => { 235 | const { middleware, action, store } = setup(makeResponse(400)) 236 | const fetchCall = action.meta[MY_FETCH_KEY] 237 | 238 | middleware(action).then( 239 | () => { 240 | expect( store.dispatch ) 241 | .to.have.been 242 | .calledWith( fetchCall.fail.lastCall.returnValue ) 243 | 244 | done() 245 | } 246 | ) 247 | }) 248 | }) 249 | 250 | context('when the fetch request results in a 500-level error', () => { 251 | it('calls `onFetchFail` with the expected arguments', done => { 252 | const { 253 | opts, 254 | middleware, 255 | action, 256 | response, 257 | store: { dispatch, getState } 258 | } = setup(makeResponse(500)) 259 | 260 | const fetchCall = action.meta[MY_FETCH_KEY] 261 | 262 | middleware(action).then( 263 | () => { 264 | expect( opts.onFetchDone ).to.not.have.been.called 265 | 266 | expect( opts.onFetchFail ) 267 | .to.have.been 268 | .calledWith( '{"hello":"world"}', response, fetchCall, action, dispatch, getState ) 269 | 270 | done() 271 | } 272 | ) 273 | }) 274 | }) 275 | 276 | context('when the fetch request encounters a network error', () => { 277 | it('calls `onNetworkError` with the expected arguments', done => { 278 | const { 279 | opts, 280 | middleware, 281 | action, 282 | store: { dispatch, getState } 283 | } = setup(undefined, true) 284 | 285 | const fetchCall = action.meta[MY_FETCH_KEY] 286 | 287 | middleware(action).then( 288 | () => { 289 | expect( opts.onFetchDone ).to.not.have.been.called 290 | expect( opts.onFetchFail ).to.not.have.been.called 291 | 292 | expect( opts.onNetworkError ) 293 | .to.have.been 294 | .calledWith( 'error', fetchCall, action, dispatch, getState ) 295 | 296 | done() 297 | } 298 | ) 299 | }) 300 | 301 | it('calls `fail` with the expected arguments', done => { 302 | const { 303 | opts, 304 | middleware, 305 | action, 306 | } = setup(undefined, true) 307 | 308 | const fetchCall = action.meta[MY_FETCH_KEY] 309 | 310 | middleware(action).then( 311 | () => { 312 | expect( fetchCall.fail ) 313 | .to.have.been 314 | .calledWith( ...opts.onNetworkError.lastCall.returnValue ) 315 | 316 | done() 317 | } 318 | ) 319 | }) 320 | 321 | it('dispatches the "fail" action through the full middleware chain', done => { 322 | const { 323 | middleware, 324 | action, 325 | store: { dispatch } 326 | } = setup(undefined, true) 327 | 328 | const fetchCall = action.meta[MY_FETCH_KEY] 329 | 330 | middleware(action).then( 331 | () => { 332 | expect( dispatch ) 333 | .to.have.been 334 | .calledWith( fetchCall.fail.lastCall.returnValue ) 335 | 336 | done() 337 | } 338 | ) 339 | }) 340 | }) 341 | 342 | describe('input validations', () => { 343 | it('expects `action.meta[MY_FETCH_KEY]` to be a plain object or undefined', () => { 344 | const { middleware } = setup(undefined, true) 345 | 346 | const action = makeAction(false) 347 | action.meta[MY_FETCH_KEY] = 'Not the right type' 348 | 349 | expect( () => middleware(action) ).to.throw( Error ) 350 | }) 351 | 352 | it('expects `done` to be a function', () => { 353 | const { middleware } = setup(undefined, true) 354 | 355 | const action = makeAction(false) 356 | action.meta[MY_FETCH_KEY].done = 'Not the right type' 357 | 358 | expect( () => middleware(action) ).to.throw( Error ) 359 | }) 360 | 361 | it('expects `fail` to be a function', () => { 362 | const { middleware } = setup(undefined, true) 363 | 364 | const action = makeAction(false) 365 | action.meta[MY_FETCH_KEY].fail = 'Not the right type' 366 | 367 | expect( () => middleware(action) ).to.throw( Error ) 368 | }) 369 | }) 370 | }) 371 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gae-flask-redux-react-starter-kit 2 | 3 | 4 | ## Prereqs 5 | 6 | * Python 2.7 (suggestion: on OSX, use the [Hombebrew](http://brew.sh/) [installation](http://docs.python-guide.org/en/latest/starting/install/osx/) and follow the brew prompt's directions to overwrite links) 7 | * [gcloud sdk](https://cloud.google.com/sdk/) ([also](http://googlecloudplatform.github.io/gcloud-python/stable/) [see](https://cloud.google.com/python/)) 8 | * [virtualenvwrapper](https://virtualenvwrapper.readthedocs.org/en/latest/) ([also](http://virtualenvwrapper.readthedocs.org/en/latest/install.html#shell-startup-file) [see](http://mathematism.com/2009/07/30/presentation-pip-and-virtualenv/)) 9 | * [Node](https://nodejs.org/en/) (>= v4) (suggestion: on OSX, use the Homebrew installation) 10 | * [react-devtools](https://github.com/facebook/react-devtools) 11 | 12 | 13 | ## Getting Set up 14 | 15 | * git clone this repo and then `cd` into it 16 | * make a virtualenv and ensure it's active. E.g., `mkvirtualenv your-virtualenv-name -a .` 17 | * `pip install --upgrade pip setuptools` 18 | * `add2virtualenv ./gae ./gae/__app_env__` 19 | * `make rehydrate` 20 | 21 | 22 | ## Development 23 | 24 | * `workon your-virtualenv-name` 25 | 26 | This will activate the project's virtualenv and `cd` you into its root directory. 27 | 28 | * `honcho start` 29 | 30 | This is at the heart of the devlopment process. It will run the Google App Engine and Webpack development servers, as well as the Karma and Pytest test runners, both in watch mode. You will have a live instance of the application and will see the logging output of each of the processes in the terminal, each process having its own color. Use this development mode to implement new features in your app; the changes will be live reloaded as the app runs and cause relevant tests to re-run. 31 | 32 | Furthermore, if you'd like to interact with the app directly, you can visit the `/_console` path in your browser (i.e., `localhost:8080/_console`). You can also instert a `werkzeug_debugger()` call in the code to interact with the app at a specific point in execution, and then when this code is activated by an HTTP request from the browser client, an interactive Werkzeug debugger stack trace will open in the browser. 33 | 34 | * `REACT_VIS=true honcho start gae webpack` 35 | 36 | The same as the previous command with the addition of setting the `REACT_VIS` environment variable. This will transform your React components with [react-transform-render-visualizer](https://github.com/spredfast/react-transform-render-visualizer). Then when you interact with your app in the browser, your React components will show where, why, and how often they re-render. You can use this information to find bottlenecks or hogs in your app's UI that could possibly use some refactoring and optimization. 37 | 38 | * `honcho start gae pytest` 39 | 40 | If you're working solely on the back end and want to spin up only the relevant processes (i.e., the GAE dev server and Pytest test runner), you can run this command. You can interact with the application via a client like [Postman](https://www.getpostman.com/). You can also work interactively with the application via the GAE dev server's [console](https://cloud.google.com/appengine/docs/python/tools/devserver?hl=en#Python_The_Interactive_Console) or via `localhost:8080/_console`. 41 | 42 | * `dev_appserver.py gae/ --show_mail_body True --allow_skipped_files True` 43 | 44 | If you'd like to interact with the application via the command line, you'll need to run the application directly, instead of through `honcho`. You can activate the [Python debugger](https://docs.python.org/2/library/pdb.html) by inserting this line `import pdb; pdb.set_trace();` in the code and calling an API endpoint that activates the line. When the debugger is active, you'll find the `pdb` prompt in the terminal running the application. See [Python debuggin with pdb](https://cloud.google.com/appengine/docs/python/tools/devserver#Python_Debugging_with_PDB). 45 | 46 | * `make coverage` 47 | 48 | This command will run the full test suites for the browser client and the back end, both in code-coverage mode. When each of the two coverage reports are done, they'll open in your default web browser. 49 | 50 | * `make check` 51 | 52 | Run linting and testing for both the GAE application and the browser client. 53 | 54 | This command will lint and test the browser client and back end code. If no linting problems are encountered and all tests pass, the process will exit without an error. 55 | 56 | * `py.test` 57 | 58 | This will perform a one-off test run of the GAE/Flask application. (NB: local config for `pytest` is in `setup.cfg`.) 59 | 60 | * `npm test` 61 | 62 | This will perform a one-off test run of the browser client. (NB: config for this command is in `package.json`.) 63 | 64 | * `bpython` and `ptpython` 65 | 66 | These are both useful Python shells. When you're using one of them, you may want to run the following `import` statement: 67 | 68 | * `import __test__.fix_sys_path` (in Python file or shell) 69 | 70 | This is used in the tests to ensure all `google.appengine.*` packages are available for import, in addition to all GAE-bundled third-party packages. There will be no live application as there is with the dev console (see notes under `honcho start gae pytest` above), so functionality that relies on a service backing it (e.g., many `google.appengine.nbd` functions rely on a datastore connection) will not be available. Nevertheless, it's still useful to have these packages available for import because you can explore them easily thanks to the autocomplete feature of the Python shells listed above. Futhermore, you can create a temporary fake application to play with, using `testbed` and a test app; see the `testbed` fixture in `gae/__test__/conftest.py` and the `app` fixture in `gae/__test__/test_app.py` for examples of how to set up a temporary test app. 71 | 72 | * `py.test --pep8 gae/__test__/ gae/app gae/config/` 73 | 74 | Use this command occasionally to check for style issues. `pep8` is very strict, and it's fine not to try to correct all the errors it raises. 75 | 76 | * debug and profiling development modes 77 | 78 | In `gae/config/development.py`, the `DEBUG` and `PROFILE` feature flags control whether the development app runs in debug and profiling mode, respectively. While the app is running, you can toggle these flags between `1` and `0` (meaning "on" and "off," respectively). When you toggle these flags (or make any changes to the app), the GAE dev server's live reloading will ensure your app's config is up to date; there's no need to restart the server for your config changes to take effect. 79 | 80 | In debug mode, any uncaught errors will open a Werkzeug interactive stack trace in the browser, allowing you to execute code against the app and investigate errors. In profiling mode, for each HTTP request, the app will log the slowest function calls involved in its processing the request-response cycle. 81 | 82 | * `Perf` in browser's dev console 83 | 84 | You can profile your React components by running the app in dev mode, opening it in the browser, and using the global `Perf` object from the browser's dev console. The `Perf` object is attached to the `window` object by the app, and [it allows](https://facebook.github.io/react/docs/perf.html) you gather different profiling statistics on your React components. Here's an example usage from the browser's dev console that could be very useful: 85 | 86 | ```js 87 | Perf.start() 88 | // perform some user interactions in the app... 89 | Perf.stop() 90 | Perf.printWasted() 91 | // view table of the time spent, and where, on unnecessary re-renders 92 | ``` 93 | 94 | * `make build && honcho start gae` 95 | 96 | This will run the app locally with a production build of the browser client. The `make build` part will also write stats about the Webpack bundle's build to `__stats__/webpack.json`, relative to the project root. Use the `webpack.json` file along with [Webpack's analyse tool](http://webpack.github.io/analyse/) to see how various libraries and modules contribute to your production bundle's size. 97 | 98 | 99 | ## Managing Dependencies 100 | 101 | ### Browser Client 102 | 103 | After updating or adding npm packages, run `npm run test:coverage` in addition to running the app and verifying things still work as expected. Then lock down the specific versions of the npm packages you're using with this command: 104 | 105 | * `npm shrinkwrap --dev` 106 | 107 | This will update `npm-shrinkwrap.json`; `npm install` uses this file by default, which will ensure you're using the exact same version of third-parth npm packages across environments. (NB: if you're familiar with Ruby on Rails development with Bundler, you can think of `npm-shrinkwrap.json` as being like `Gemfile.lock`.) 108 | 109 | ### GAE/Flask Application 110 | 111 | * `make pip-app` or `make pip-dev` 112 | 113 | To add a new dependency to your project, add `==` to `requirements.app.txt` or `requirements.dev.txt`, and then run the corresponding command above to install the dependency. `requirements.app.txt` is used to install your app's dependency in `gae/__app_env__`, which is deployed with your app to the GAE servers. `requirements.dev.txt` is used to install all other dependencies (e.g., `pytest`), which are only needed for development and are installed in `virtualenvwrapper`'s default manner. 114 | 115 | * `pipdeptree` 116 | 117 | This command will produce a visualization of your pip-installed packages, which is useful for maintaining the `requirements.*.txt` files. All of the package names that align to the left of the terminal window comprise the minimal set of names for your `requirements.dev.txt` and `requirements.app.txt` files. If there are any sub-dependencies whose version numbers you'd like to pin, add them to the the relevant requirements files as well. 118 | 119 | ### Verify Everything's Working 120 | 121 | Installing new dependencies frequently comes with changes, sometimes radical, to how the development process or the app itself works. A fairly quick and comprehensive way to verify everything's still working as expected is to follow these steps: 122 | 123 | * Run `make coverage` and verify all tests and coverage reports run successfully. 124 | * Run `make clean && honcho start gae webpack`, open the app in the browser with the devtools console open, and interact with a selection of features/pages in the app. Look at the browser's devtools console as well as the log output in the terminal you've used to run the above command and verify no unexpected errors occur. 125 | * Run `make build && honcho start gae` and follow the same steps outlined in the point above. Now you're verifying that the build version of the app works with no unexpected errors. 126 | 127 | 128 | ## Deployment 129 | 130 | Make sure you have a GAE account and have created the project under your account ([details](https://cloud.google.com/appengine/docs/python/gettingstartedpython27/uploading)). 131 | 132 | Also make sure you've replaced "your-app-id-here" with your project's ID under the `application` field in `gae/app.yaml` and in the `deploy` field of the `Makefile`. 133 | 134 | Then use this command to build and deploy your project: 135 | 136 | * `make deploy` 137 | 138 | This will build and deploy your app. (NB: the `make deploy` command runs `make check` as a dependency before the build and deploy steps are run.) 139 | 140 | Once the app is deployed you can use the [GAE console](https://console.cloud.google.com) (or [this alternative](https://appengine.google.com/)) and the [remote API](https://cloud.google.com/appengine/docs/python/tools/remoteapi) to manage and interact with the deployed application. 141 | 142 | 143 | ## Notes and Bookmarks 144 | 145 | ### Use of `deepFreeze` in test mode for the browser client 146 | 147 | Sharing mutable data among your components is bad only if the data is actually mutated. And it's worse if it's mutated without your knowing! Using [`deepFreeze`](https://github.com/AnatoliyGatt/deep-freeze-node) in your tests will let you know if any mutations happen in your app's runtime. 148 | 149 | The browser client's tests make extensive use of `deepFreeze`. This means that any unexpected mutative behavior in the app will throw an error during test time, warning you of this undesirable behavior. Continuing to write tests like this as your app develops will protect you from accidentally introducing undesirable behavior in your app (maybe passing your state as the first argument to `Object.assign`). 150 | 151 | This has the added benefit of freeing you from having to always use ImmutableJS structures, or some other immutable structures, just for the sake of implementing an app that treats data as immutable. Then you're free to choose ImmutableJS only when there's a clear performance or convenience benefit. Sometimes it's convenient just to use plain JS objects for read-only state that will not be updated during the app's life. 152 | 153 | ### Importing from `react-pure-render/component` 154 | 155 | When importing this class, I bind it to the name "Component" rather than "PureComponent"; this is to [help](https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/prop-types.md#about-component-detection) eslint detect React components in the code and enforce important lint rules. 156 | 157 | ### [Redux principles](https://egghead.io/series/getting-started-with-redux) 158 | 159 | * Minimal actions and state 160 | 161 | Actions carry just enough information for the store to generate the right state; the store carries just enough state for the React components to provide the right UI. Any state that can be computed from other pieces of state in the store should be taken out of the store and left implicit; this data can be computed later by containers or components of the app, using the explicit state of the store. Even if this implicit data is costly to compute and the same data may need to be recomputed several times throughout the life of the app, it's better left implicit; you can use pure-rendering components and memoized pure functions down the line to compute the data. This provides a good solution: your app is kept simple by managing the fewest pieces of state as possible, and your app is kept performant by using memoized pure functions and pure-rendering components to calculate and render implicit state the fewest number of times as possible for the UI. 162 | 163 | ### Flask callbacks and decorators 164 | 165 | Using Flask-RESTful, the order in which callbacks and decorators are invoked in a request-response cycle is roughly this: 166 | 167 | Incoming request: 168 | 169 | * app `before_request` 170 | * blueprint `before_app_request` 171 | * blueprint `before_request` 172 | * api decorators, rightmost to leftmost 173 | * resource decorators, rightmost to leftmost 174 | * view fn decorators, top to bottom 175 | 176 | Outgoing response: 177 | 178 | * view fn decorators, bottom to top 179 | * resource decorators, leftmost to rightmost 180 | * api decorators, leftmost to rightmost 181 | * `after_this_request` 182 | * blueprint `after_request` 183 | * blueprint `after_app_request` 184 | * app `after_request` 185 | 186 | After response is sent: 187 | 188 | * app teardown_request 189 | 190 | That's the rough idea. However, for peer callbacks, the order of invocation depends on when the peers are registered with the app. 191 | 192 | For the peers app `before_request` and all blueprint `before_app_request`s, the order in which they're invoked for an incoming request is the order in which they're registered with the app. 193 | 194 | For the peers app `after_request` and all blueprint `after_app_request`s, the order in which they're invoked for an outgoing response is the reverse of the order in which they're registered with the app. 195 | 196 | This information about the sequencing of peers is important. For maintainability, these high-level callbacks should depend as little as possible on their relative sequencing. However, for decorators, it's much easier to maintain sequential dependencies since there sequence is explicit in their colocation in the code and, possibly, their functional contracts. 197 | --------------------------------------------------------------------------------