├── 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 |
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 |
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 |
--------------------------------------------------------------------------------