├── .babelrc ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE.md ├── README.md ├── example ├── index.js ├── package.json ├── src │ ├── client │ │ ├── components │ │ │ ├── App.jsx │ │ │ ├── Loader.jsx │ │ │ ├── TodoApp.jsx │ │ │ ├── TodoInput.jsx │ │ │ ├── TodoItem.jsx │ │ │ ├── TodoList.jsx │ │ │ └── __tests__ │ │ │ │ └── TodoApp.spec.js │ │ ├── index.js │ │ ├── mutations │ │ │ └── todo.js │ │ └── routes.jsx │ └── server │ │ ├── data.js │ │ ├── index.js │ │ └── schema.js └── webpack.config.js ├── images └── resgression-example.png ├── package.json └── src ├── components ├── Adrenaline.jsx ├── container.jsx └── presenter.jsx ├── index.js ├── network └── defaultNetworkLayer.js ├── test-utils └── expect.js ├── test.js └── utils ├── __tests__ ├── getDisplayName.spec.js └── shallowEqual.spec.js ├── getDisplayName.js ├── isPlainObject.js └── shallowEqual.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "react", "stage-0"] 3 | } 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | example/webpack.*.js 4 | example/index.js 5 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "parserOptions": { 4 | "ecmaVersion": "7", 5 | "sourceType": "module", 6 | "ecmaFeatures": { 7 | "jsx": true 8 | } 9 | }, 10 | "env": { 11 | "browser": true, 12 | "node": true 13 | }, 14 | "plugins": [ 15 | "react" 16 | ], 17 | "extends": ["eslint:recommended"], 18 | "settings": { 19 | "react": { 20 | "pragma": "React", 21 | "version": "0.14.0" 22 | } 23 | }, 24 | "rules": { 25 | "strict": [2, "never"], 26 | "no-var": 2, 27 | "prefer-const": 2, 28 | "no-shadow": 0, 29 | "no-shadow-restricted-names": 2, 30 | "no-unused-vars": [2, { 31 | "vars": "local", 32 | "args": "after-used" 33 | }], 34 | "no-use-before-define": 0, 35 | "comma-dangle": [2, "always-multiline"], 36 | "no-cond-assign": [2, "always"], 37 | "no-console": 1, 38 | "no-debugger": 1, 39 | "no-alert": 1, 40 | "no-constant-condition": 1, 41 | "no-dupe-keys": 2, 42 | "no-duplicate-case": 2, 43 | "no-empty": 2, 44 | "no-ex-assign": 2, 45 | "no-extra-boolean-cast": 0, 46 | "no-extra-semi": 2, 47 | "no-func-assign": 2, 48 | "no-inner-declarations": 2, 49 | "no-invalid-regexp": 2, 50 | "no-irregular-whitespace": 2, 51 | "no-obj-calls": 2, 52 | "quote-props": [2, "as-needed"], 53 | "no-sparse-arrays": 2, 54 | "no-unreachable": 2, 55 | "use-isnan": 2, 56 | "block-scoped-var": 0, 57 | "consistent-return": 2, 58 | "curly": [2, "multi-line"], 59 | "default-case": 2, 60 | "dot-notation": [2, { 61 | "allowKeywords": true 62 | }], 63 | "eqeqeq": 2, 64 | "guard-for-in": 2, 65 | "no-caller": 2, 66 | "no-else-return": 2, 67 | "no-eq-null": 2, 68 | "no-eval": 2, 69 | "no-extend-native": 2, 70 | "no-extra-bind": 2, 71 | "no-fallthrough": 2, 72 | "no-floating-decimal": 2, 73 | "no-implied-eval": 2, 74 | "no-lone-blocks": 2, 75 | "no-loop-func": 2, 76 | "no-multi-str": 2, 77 | "no-native-reassign": 2, 78 | "no-new": 2, 79 | "no-new-func": 2, 80 | "no-new-wrappers": 2, 81 | "no-octal": 2, 82 | "no-octal-escape": 2, 83 | "no-param-reassign": 2, 84 | "no-proto": 2, 85 | "no-redeclare": 2, 86 | "no-return-assign": 2, 87 | "no-script-url": 2, 88 | "no-self-compare": 2, 89 | "no-sequences": 2, 90 | "no-throw-literal": 2, 91 | "no-with": 2, 92 | "radix": 2, 93 | "vars-on-top": 2, 94 | "wrap-iife": [2, "any"], 95 | "yoda": 2, 96 | "indent": [2, 2], 97 | "brace-style": [2, 98 | "stroustrup", { 99 | "allowSingleLine": true 100 | }], 101 | "quotes": [ 102 | 2, "single", "avoid-escape" 103 | ], 104 | "camelcase": [2, { 105 | "properties": "never" 106 | }], 107 | "comma-spacing": [2, { 108 | "before": false, 109 | "after": true 110 | }], 111 | "comma-style": [2, "last"], 112 | "eol-last": 2, 113 | "func-names": 1, 114 | "key-spacing": [2, { 115 | "beforeColon": false, 116 | "afterColon": true 117 | }], 118 | "new-cap": [2, { 119 | "newIsCap": true 120 | }], 121 | "no-multiple-empty-lines": [2, { 122 | "max": 2 123 | }], 124 | "no-nested-ternary": 2, 125 | "no-new-object": 2, 126 | "no-spaced-func": 2, 127 | "no-trailing-spaces": 2, 128 | "no-extra-parens": 2, 129 | "no-underscore-dangle": 0, 130 | "one-var": [2, "never"], 131 | "padded-blocks": [2, "never"], 132 | "semi": [2, "always"], 133 | "semi-spacing": [2, { 134 | "before": false, 135 | "after": true 136 | }], 137 | "keyword-spacing": 2, 138 | "space-before-blocks": 2, 139 | "space-before-function-paren": [2, "never"], 140 | "space-infix-ops": 2, 141 | "spaced-comment": 2, 142 | // React rules 143 | "react/display-name": 2, 144 | "react/jsx-no-duplicate-props": 2, 145 | "react/jsx-no-undef": 2, 146 | "react/jsx-uses-react": 2, 147 | "react/jsx-uses-vars": 2, 148 | "react/no-danger": 2, 149 | "react/no-deprecated": 2, 150 | "react/no-did-mount-set-state": [2, 'allow-in-func'], 151 | "react/no-did-update-set-state": [2, 'allow-in-func'], 152 | "react/no-direct-mutation-state": 2, 153 | "react/no-is-mounted": 2, 154 | "react/no-unknown-property": 2, 155 | "react/prop-types": 2, 156 | "react/react-in-jsx-scope": 2 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | example/.tmp 4 | example/node_modules 5 | example/uploads 6 | *.log 7 | *.DS_Store 8 | .idea 9 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .* 2 | node_modules 3 | example 4 | images 5 | src 6 | *__tests__ 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "4" 4 | before_deploy: 5 | - npm test 6 | - npm run build 7 | deploy: 8 | provider: npm 9 | email: gyzerok@gmail.com 10 | api_key: 11 | secure: jLGdYG8wRfJW8MxjhJHQvUaLwQLjpqpacSON4brixl5qasaG+yUvNW/1h3FdzXee9LGuTpRQJmMVUW/DWdyAI4OVzS8nrV1GUdfNvxPul6yM+dhcsmmLVXotTzOUtb9JVAmfbpCEhl95+GMFUKrEZYQ2svqvKxWLWS2hvgGXXAO5tVzvu8OcNaboXNhn4iaIBmK62XBnqX+20eQ8oklWgx1+/APbAy1glSddWTCV1DYgx0GnYsmRxS8rQtOnkYIt5hdJpHCH5edW5o1nz+xtIqr+FDyXfhV+4BT/rmtgqhnxkCqisJsifk9fsf+BVDaI2Mabp5gYhCLTFFqsX26s+U5+qOapzwx3P1llH94feoAaBhloABbV573IAQEfnSlBvDFzA2e79Hg7OUpi7mJ87R3Ud32GDQzJTIqBa1D4mVqcORAysUyqiopceHJwwaJVfvP0bYykiXSk8Ahqnc5VnncmAxfy49w9ZoinhFJ95Z+OiI/0k7HP/LpKLXUHOgOBhJoVN/6ku4aoKnTMSv8UuSlCKa6qlkDU2sPUEriT6q+zpqW96AaYN0lWuwe3ICryxzJ+eWyHUXVbSQTF7A7xTFo7R1g0AcxDuJU7mrDX2cJXcA5hi59AioIcauTm8gKO4IYSUu6RcJ1TCDiTk8oWVzQ42NCQbeb0/d6hp6Bg2zQ= 12 | on: 13 | branch: master 14 | tags: true 15 | node: "4" 16 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-2016 Fedor Nezhivoi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Adrenaline 2 | 3 | [![build status](https://img.shields.io/travis/gyzerok/adrenaline/master.svg?style=flat-square)](https://travis-ci.org/gyzerok/adrenaline) 4 | [![npm version](https://img.shields.io/npm/v/adrenaline.svg?style=flat-square)](https://www.npmjs.com/package/adrenaline) 5 | [![npm downloads](https://img.shields.io/npm/dm/adrenaline.svg?style=flat-square)](https://www.npmjs.com/package/adrenaline) 6 | 7 | This library provides a subset of [Relay](https://github.com/facebook/relay)'s behaviour with a cleaner API. 8 | 9 | ## Why? 10 | 11 | Relay is a great framework with exciting ideas behind it. The downside is that in order to get all the cool features, you need to deal with a complex API. Relay provides you a lot of tricky optimizations which probably are more suitable for huge projects. In small, medium and even large ones you would prefer to have better developer experience while working with a simple, minimalistic set of APIs. 12 | 13 | Adrenaline intends to provide you Relay-like ability to describe your components with declarative data requirements, while keeping the API as simple as possible. You are free to use it with different libraries like Redux, React Router, etc. 14 | 15 | ## When not to use it? 16 | 17 | - You have a huge project and highly need tricky optimisations to reduce client-server traffic. 18 | - When you don't understand why you should prefer Adrenaline to Relay. 19 | 20 | ## Installation 21 | 22 | `npm install --save adrenaline` 23 | 24 | Adrenaline requires **React 15.0 or later.** 25 | 26 | Adrenaline uses `fetch` under the hood so you need to install the [polyfill](https://github.com/github/fetch) by yourself. 27 | 28 | `npm install --save whatwg-fetch` 29 | 30 | and then import it at the very top of your entry JavaScript file: 31 | 32 | ```js 33 | import 'whatwg-fetch'; 34 | import React from 'react'; 35 | import ReactDOM from 'react-dom'; 36 | import { Adrenaline } from 'adrenaline'; 37 | 38 | import App from './components/App'; 39 | 40 | ReactDOM.render( 41 | 42 | 43 | , 44 | document.getElementById('root') 45 | ) 46 | ``` 47 | 48 | ## API 49 | 50 | Adrenaline follows the idea of [Presentational and Container Components](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0#.b5g7ctse2) 51 | 52 | ### `` 53 | 54 | Root of your application should be wrapped with Adrenaline component. This component is a provider component which injects some helpful stuff into your React application. 55 | 56 | prop name | type | default/required | purpose 57 | ----------|--------|------------------|-------- 58 | endpoint | string | "/graphql" | URI of your GraphQL endpoint 59 | 60 | ### `container({ variables, query })(Component)` 61 | 62 | In Adrenaline, you create container components mostly for your route handlers. The purpose of containers is to collect data requirements from presentation components in a single GraphQL query. They also behave like view controllers and are able to speak to the outside world using mutations. 63 | 64 | key | type | default/required | purpose 65 | ----------|--------------------------|------------------|-------- 66 | variables | (props: Props) => Object | () => ({}) | describe query variables as a pure function of props 67 | query | string | **required** | your GraphQL query for this container 68 | 69 | 70 | ```javascript 71 | import React, { Component, PropTypes } from 'react'; 72 | import { container } from 'adrenaline'; 73 | import TodoList from './TodoList'; 74 | 75 | class UserItem extends Component { 76 | static propTypes = { 77 | viewer: PropTypes.object.isRequired, 78 | } 79 | /* ... */ 80 | } 81 | 82 | export default container({ 83 | variables: (props) => ({ 84 | id: props.userId, 85 | }), 86 | query: ` 87 | query ($id: ID!) { 88 | viewer(id: $id) { 89 | id, 90 | name, 91 | ${TodoList.getFragment('todos')} 92 | } 93 | } 94 | `, 95 | })(UserItem); 96 | ``` 97 | 98 | Containers may also pass 2 additional properties to your component: `mutate` and `isFetching`. 99 | 100 | * `mutate({ mutation: String, variables: Object = {}, invalidate: boolean = true }): Promise`: You need to use this function in order to perform mutations. `invalidate` argument means you need to resolve data declarations after mutation. 101 | * `isFetching: boolean`: This property helps you understand if your component is in the middle of resolving data. 102 | 103 | ### `presenter({ fragments })(Component)` 104 | 105 | As in the [presentational components idea](https://github.com/rackt/react-redux#dumb-components-are-unaware-of-redux), all your dumb components may be declared as simple React components. But if you want to declare your data requirements in a way similar to Relay, you can use the `presenter()` higher-order component. 106 | 107 | ```javascript 108 | import React, { Component } from 'react'; 109 | import { presenter } from 'adrenaline'; 110 | 111 | class TodoList extends Component { 112 | /* ... */ 113 | } 114 | 115 | export default presenter({ 116 | fragments: { 117 | todos: ` 118 | fragment on User { 119 | todos { 120 | id, 121 | text 122 | } 123 | } 124 | `, 125 | }, 126 | })(TodoList); 127 | ``` 128 | 129 | 130 | ## Using ES7 decorators 131 | Adrenaline works as higher-order components, so you can decorate your container components using ES7 decorators 132 | 133 | ```javascript 134 | import { container } from 'adrenaline' 135 | 136 | @container({ 137 | variables: (props) => ({ 138 | id: props.userId, 139 | }), 140 | query: ` 141 | query ($id: ID!) { 142 | viewer(id: $id) { 143 | id, 144 | name, 145 | ${TodoList.getFragment('todos')} 146 | } 147 | } 148 | ` 149 | }) 150 | export default class extends Component { 151 | static propTypes = { 152 | viewer: PropTypes.object.isRequired, 153 | } 154 | /* ... */ 155 | } 156 | ``` 157 | 158 | 159 | ### Mutations 160 | 161 | You can declare your mutations as simple as: 162 | 163 | ```javascript 164 | const createTodo = ` 165 | mutation ($text: String, $owner: ID) { 166 | createTodo(text: $text, owner: $owner) { 167 | id, 168 | text, 169 | owner { 170 | id 171 | } 172 | } 173 | } 174 | `; 175 | ``` 176 | 177 | Then you can use this mutation with your component: 178 | 179 | ```javascript 180 | import React, { Component, PropTypes } from 'react'; 181 | import { createSmartComponent } from 'adrenaline'; 182 | 183 | class UserItem extends Component { 184 | static propTypes = { 185 | viewer: PropTypes.object.isRequired, 186 | } 187 | 188 | onSomeButtonClick() { 189 | this.props.mutate({ 190 | mutation: createTodo, 191 | variables: { 192 | text: hello, 193 | owner: this.props.viewer.id 194 | }, 195 | }); 196 | } 197 | 198 | render() { 199 | /* render some stuff */ 200 | } 201 | } 202 | ``` 203 | 204 | ### Testing 205 | 206 | There is a common problem I've discovered so far while developing applications. When you change the GraphQL schema, you'd like to know which particular subtrees in your applications need to be fixed. And you probably do not want to check this by running your application and going through it by hand. 207 | 208 | For this case, Adrenaline provides you helper utilities for integration testing (currently for `expect` only). You can use `toBeValidAgainst` for checking your components' data requirements against your schema with GraphQL validation mechanism. 209 | 210 | ```js 211 | import expect from 'expect'; 212 | import TestUtils from 'adrenaline/lib/test'; 213 | 214 | import schema from 'path/to/schema'; 215 | // TodoApp is a container component 216 | import TodoApp from 'path/to/TodoApp'; 217 | 218 | expect.extend(TestUtils.expect); 219 | 220 | describe('Queries regression', () => { 221 | it('for TodoApp', () => { 222 | expect(TodoApp).toBeValidAgainst(schema); 223 | }); 224 | }); 225 | ``` 226 | 227 | ![Image](https://raw.githubusercontent.com/gyzerok/adrenaline/master/images/resgression-example.png) 228 | -------------------------------------------------------------------------------- /example/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | global.__CLIENT__ = false; 4 | require('babel-register'); 5 | 6 | var webpack = require('webpack'); 7 | var WebpackDevServer = require('webpack-dev-server'); 8 | var config = require('./webpack.config'); 9 | var path = require('path'); 10 | 11 | var appPort = 1337; 12 | var proxy = 'http://localhost:' + appPort; 13 | 14 | var devServer = new WebpackDevServer(webpack(config), { 15 | contentBase: path.join(__dirname, '.tmp'), 16 | publicPath: '/public/', 17 | hot: true, 18 | historyApiFallback: true, 19 | proxy: [ 20 | { 21 | path: /^(?!\/public).*$/, 22 | target: proxy 23 | } 24 | ] 25 | }); 26 | 27 | devServer.listen(3000, 'localhost', function () { 28 | console.log('Listening at http://%s:%s', 'localhost', 3000); 29 | }); 30 | 31 | var app = require('./src/server').default; 32 | app.listen(appPort, function () {}); 33 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "server.js", 6 | "scripts": { 7 | "test": "mocha --compilers js:babel-register $(find ./src -name '*.spec.js')", 8 | "start": "node index.js" 9 | }, 10 | "author": "Fedor Nezhivoi ", 11 | "license": "MIT", 12 | "dependencies": { 13 | "adrenaline": "file:../", 14 | "babel-register": "^6.7.2", 15 | "body-parser": "^1.13.3", 16 | "express": "^4.13.3", 17 | "express-graphql": "^0.5.0", 18 | "graphql": "^0.6.0", 19 | "history": "^1.17.0", 20 | "react": "^15.0.0", 21 | "react-dom": "^15.0.0", 22 | "react-router": "^1.0.0", 23 | "whatwg-fetch": "^0.9.0" 24 | }, 25 | "devDependencies": { 26 | "babel-core": "^6.7.4", 27 | "babel-loader": "^6.2.4", 28 | "babel-preset-es2015": "^6.6.0", 29 | "babel-preset-react": "^6.5.0", 30 | "babel-preset-stage-0": "^6.5.0", 31 | "expect": "^1.13.4", 32 | "mocha": "^2.3.4", 33 | "webpack": "^1.12.14", 34 | "webpack-dev-server": "^1.11.0" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /example/src/client/components/App.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | 3 | export default class App extends Component { 4 | render() { 5 | return ( 6 |
7 |

Header

8 | {this.props.children} 9 |
10 | ); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /example/src/client/components/Loader.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | 3 | export default class Loader extends Component { 4 | render() { 5 | return ( 6 |
Loading
7 | ); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /example/src/client/components/TodoApp.jsx: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | import React, { Component, PropTypes } from 'react'; 4 | import { container } from 'adrenaline'; 5 | 6 | import TodoInput from './TodoInput'; 7 | import TodoList from './TodoList'; 8 | import Loader from './Loader'; 9 | import { createTodo } from '../mutations/todo'; 10 | 11 | class TodoApp extends Component { 12 | static propTypes = { 13 | viewer: PropTypes.object, 14 | isFetching: PropTypes.bool.isRequired, 15 | mutate: PropTypes.func.isRequired, 16 | } 17 | 18 | createTodo = (args) => { 19 | this.props.mutate({ 20 | mutation: createTodo, 21 | variables: { input: args }, 22 | }); 23 | } 24 | 25 | render() { 26 | const { viewer, isFetching } = this.props; 27 | 28 | if (isFetching) { 29 | return ; 30 | } 31 | 32 | return ( 33 |
34 | 35 | 36 |
37 | ); 38 | } 39 | } 40 | 41 | export default container({ 42 | query: ` 43 | query { 44 | viewer { 45 | id, 46 | ${TodoList.getFragment('todos')} 47 | } 48 | } 49 | `, 50 | })(TodoApp); 51 | -------------------------------------------------------------------------------- /example/src/client/components/TodoInput.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | 3 | const ENTER_KEY_CODE = 13; 4 | 5 | export default class TodoInput extends Component { 6 | static propTypes = { 7 | createTodo: PropTypes.func.isRequired, 8 | } 9 | 10 | onEnter = (e) => { 11 | const { createTodo } = this.props; 12 | const input = this._input; 13 | 14 | if (!input.value.length) return; 15 | 16 | if (e.keyCode === ENTER_KEY_CODE) { 17 | createTodo({ text: input.value }); 18 | input.value = ''; 19 | } 20 | } 21 | 22 | render() { 23 | return ( 24 | { this._input = input }} 26 | type="text" 27 | onKeyDown={this.onEnter} 28 | autoFocus="true" 29 | /> 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /example/src/client/components/TodoItem.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import { presenter } from 'adrenaline'; 3 | 4 | class TodoItem extends Component { 5 | static propTypes = { 6 | todo: PropTypes.object.isRequired, 7 | } 8 | 9 | render() { 10 | const { todo } = this.props; 11 | 12 | return ( 13 |
  • {todo.text}
  • 14 | ); 15 | } 16 | } 17 | 18 | export default presenter({ 19 | fragments: { 20 | todo: ` 21 | fragment on Todo { 22 | text 23 | } 24 | `, 25 | }, 26 | })(TodoItem); 27 | -------------------------------------------------------------------------------- /example/src/client/components/TodoList.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import { presenter } from 'adrenaline'; 3 | 4 | import TodoItem from './TodoItem'; 5 | 6 | class TodoList extends Component { 7 | static propTypes = { 8 | todos: PropTypes.array, 9 | } 10 | 11 | static defaultProps = { 12 | todos: [], 13 | } 14 | 15 | render() { 16 | const { todos } = this.props; 17 | 18 | return ( 19 |
      20 | {todos.map(todo => 21 | 22 | )} 23 |
    24 | ); 25 | } 26 | } 27 | 28 | export default presenter({ 29 | fragments: { 30 | todos: ` 31 | fragment on User { 32 | todos { 33 | id, 34 | ${TodoItem.getFragment('todo')} 35 | } 36 | } 37 | `, 38 | }, 39 | })(TodoList); 40 | -------------------------------------------------------------------------------- /example/src/client/components/__tests__/TodoApp.spec.js: -------------------------------------------------------------------------------- 1 | import expect from 'expect'; 2 | import TestUtils from 'adrenaline/lib/test'; 3 | 4 | import schema from '../../../server/schema'; 5 | import TodoApp from '../TodoApp'; 6 | 7 | expect.extend(TestUtils.expect); 8 | 9 | describe('Queries regression', () => { 10 | it('for TodoApp', () => { 11 | expect(TodoApp).toBeValidAgainst(schema); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /example/src/client/index.js: -------------------------------------------------------------------------------- 1 | import 'whatwg-fetch'; 2 | import React from 'react'; 3 | import ReactDOM from 'react-dom'; 4 | import { Router } from 'react-router'; 5 | import { Adrenaline } from 'adrenaline'; 6 | import createBrowserHistory from 'history/lib/createBrowserHistory'; 7 | 8 | import routes from './routes'; 9 | 10 | const history = createBrowserHistory(); 11 | 12 | const rootNode = document.getElementById('root'); 13 | ReactDOM.render( 14 | 15 | 16 | , 17 | rootNode 18 | ); 19 | -------------------------------------------------------------------------------- /example/src/client/mutations/todo.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | export const createTodo = ` 4 | mutation AppMutation($input: TodoInput) { 5 | createTodo(input: $input) { 6 | id 7 | } 8 | } 9 | `; 10 | -------------------------------------------------------------------------------- /example/src/client/routes.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Route } from 'react-router'; 3 | import App from './components/App'; 4 | import TodoApp from './components/TodoApp'; 5 | 6 | export default ( 7 | 8 | 9 | 10 | ); 11 | -------------------------------------------------------------------------------- /example/src/server/data.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | let idx = 4; 4 | let data = { 5 | users: [ 6 | { 7 | id: 'u-1', 8 | name: 'User1', 9 | todos: ['t-1', 't-2', 't-3'], 10 | }, 11 | ], 12 | todos: [ 13 | { 14 | id: 't-1', 15 | text: 'hey', 16 | owner: 'u-1', 17 | createdAt: (new Date()).toString(), 18 | }, 19 | { 20 | id: 't-2', 21 | text: 'ho', 22 | owner: 'u-1', 23 | createdAt: (new Date()).toString(), 24 | }, 25 | { 26 | id: 't-3', 27 | text: 'lets go', 28 | owner: 'u-1', 29 | createdAt: (new Date()).toString(), 30 | }, 31 | ], 32 | }; 33 | 34 | export function findTodoById(id) { 35 | return data.todos.filter(t => t.id === id)[0]; 36 | } 37 | 38 | export function findTodo({ count }) { 39 | return count ? data.todos.slice(0, count) : data.todos; 40 | } 41 | 42 | export function createTodo({ text }) { 43 | const todo = { 44 | id: 't-' + idx++, 45 | text: text, 46 | owner: 'u-1', 47 | createdAt: (new Date()).toString(), 48 | }; 49 | data.todos.push(todo); 50 | return todo; 51 | } 52 | 53 | export function findUser() { 54 | return data.users[0]; 55 | } 56 | -------------------------------------------------------------------------------- /example/src/server/index.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | import { join } from 'path'; 4 | import express from 'express'; 5 | import graphqlHTTP from 'express-graphql'; 6 | 7 | import schema from './schema'; 8 | import * as connection from './data'; 9 | 10 | 11 | const app = express(); 12 | 13 | const publicPath = join(__dirname, '..', '..', '.tmp'); 14 | app.use('/public', express.static(publicPath)); 15 | 16 | app.use('/graphql', graphqlHTTP({ 17 | schema, 18 | context: { connection }, 19 | })); 20 | 21 | app.get('*', (req, res) => { 22 | res.send(` 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 |
    31 | 32 | 33 | 34 | `); 35 | }); 36 | 37 | export default app; 38 | -------------------------------------------------------------------------------- /example/src/server/schema.js: -------------------------------------------------------------------------------- 1 | import { 2 | GraphQLObjectType, 3 | GraphQLID, 4 | GraphQLString, 5 | GraphQLInt, 6 | GraphQLList, 7 | GraphQLNonNull, 8 | GraphQLEnumType, 9 | GraphQLInputObjectType, 10 | GraphQLSchema, 11 | } from 'graphql'; 12 | 13 | const todoType = new GraphQLObjectType({ 14 | name: 'Todo', 15 | description: 'Todo type', 16 | fields: () => ({ 17 | id: { 18 | type: new GraphQLNonNull(GraphQLID), 19 | description: 'Todo id', 20 | }, 21 | text: { 22 | type: new GraphQLNonNull(GraphQLString), 23 | description: 'Todo text', 24 | }, 25 | owner: { 26 | type: userType, 27 | resolve: (todo, _, { connection }) => { 28 | return connection.findUser(); 29 | }, 30 | }, 31 | createdAt: { 32 | type: new GraphQLNonNull(GraphQLString), 33 | description: 'Todo creation date', 34 | }, 35 | }), 36 | }); 37 | 38 | const userType = new GraphQLObjectType({ 39 | name: 'User', 40 | fields: () => ({ 41 | id: { 42 | type: new GraphQLNonNull(GraphQLID), 43 | }, 44 | name: { 45 | type: GraphQLString, 46 | }, 47 | todos: { 48 | type: new GraphQLList(todoType), 49 | args: { 50 | count: { 51 | name: 'count', 52 | type: GraphQLInt, 53 | }, 54 | }, 55 | resolve: (user, params, { connection }) => { 56 | return connection.findTodo(params); 57 | }, 58 | }, 59 | }), 60 | }); 61 | 62 | const enumType = new GraphQLEnumType({ 63 | name: 'Test', 64 | values: { 65 | ONE: { 66 | value: 1, 67 | }, 68 | TWO: { 69 | value: 2, 70 | }, 71 | }, 72 | }); 73 | 74 | const todoInput = new GraphQLInputObjectType({ 75 | name: 'TodoInput', 76 | fields: () => ({ 77 | text: { type: GraphQLString }, 78 | }), 79 | }); 80 | 81 | export default new GraphQLSchema({ 82 | query: new GraphQLObjectType({ 83 | name: 'Query', 84 | fields: () => ({ 85 | viewer: { 86 | type: userType, 87 | resolve: (root, args, { connection }) => { 88 | return connection.findUser(); 89 | }, 90 | }, 91 | test: { 92 | type: enumType, 93 | resolve: () => 1, 94 | }, 95 | }), 96 | }), 97 | mutation: new GraphQLObjectType({ 98 | name: 'Mutation', 99 | fields: () => ({ 100 | createTodo: { 101 | type: todoType, 102 | args: { 103 | input: { 104 | type: todoInput, 105 | }, 106 | }, 107 | resolve: (root, { input }, { connection }) => { 108 | return connection.createTodo(input); 109 | }, 110 | }, 111 | }), 112 | }), 113 | }); 114 | -------------------------------------------------------------------------------- /example/webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var webpack = require('webpack'); 3 | 4 | module.exports = { 5 | devtool: 'source-map', 6 | entry: { 7 | bundle: path.join(__dirname, 'src', 'client') 8 | }, 9 | output: { 10 | path: path.join(__dirname, '.tmp'), 11 | filename: "bundle.js" 12 | }, 13 | resolve: { 14 | extensions: ['', '.js', '.jsx'], 15 | }, 16 | module: { 17 | loaders: [ 18 | { 19 | test: /\.jsx?$/, 20 | exclude: /node_modules/, 21 | loader: 'babel' 22 | } 23 | ] 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /images/resgression-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gyzerok/adrenaline/2e7692f563caa594612082a3491fb6fa1f7c2aa2/images/resgression-example.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "adrenaline", 3 | "version": "1.0.2", 4 | "description": "Relay-like framework with simplier API", 5 | "main": "./lib/index.js", 6 | "scripts": { 7 | "test": "mocha --compilers js:babel-register $(find ./src -name '*.spec.js')", 8 | "build": "node_modules/.bin/babel src --ignore __tests__ --out-dir lib/ --source-maps", 9 | "prepublish": "npm run build" 10 | }, 11 | "author": "Fedor Nezhivoi ", 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/gyzerok/adrenaline.git" 15 | }, 16 | "license": "MIT", 17 | "keywords": [ 18 | "adrenaline", 19 | "react", 20 | "redux", 21 | "react-redux", 22 | "bindings", 23 | "relay", 24 | "graphql", 25 | "flux" 26 | ], 27 | "dependencies": { 28 | "invariant": "^2.2.1" 29 | }, 30 | "peerDependencies": { 31 | "react": "^15.0.0", 32 | "graphql": "^0.6.0" 33 | }, 34 | "devDependencies": { 35 | "babel-cli": "^6.6.5", 36 | "babel-eslint": "^6.0.0", 37 | "babel-preset-es2015": "^6.6.0", 38 | "babel-preset-react": "^6.5.0", 39 | "babel-preset-stage-0": "^6.5.0", 40 | "babel-register": "^6.7.2", 41 | "eslint": "^2.4.0", 42 | "eslint-plugin-react": "^4.2.3", 43 | "expect": "^1.14.0", 44 | "mocha": "^2.4.5", 45 | "webpack": "^1.12.2" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/components/Adrenaline.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | 3 | import defaultNetworkLayer from '../network/defaultNetworkLayer'; 4 | 5 | 6 | export default class Adrenaline extends Component { 7 | static childContextTypes = { 8 | query: PropTypes.func, 9 | mutate: PropTypes.func, 10 | } 11 | 12 | static propTypes = { 13 | endpoint: PropTypes.string, 14 | networkLayer: PropTypes.object, 15 | children: PropTypes.element.isRequired, 16 | } 17 | 18 | static defaultProps = { 19 | endpoint: '/graphql', 20 | networkLayer: defaultNetworkLayer, 21 | } 22 | 23 | getChildContext() { 24 | const { endpoint, networkLayer } = this.props; 25 | 26 | return { 27 | query: (query, variables) => { 28 | return networkLayer.performQuery(endpoint, query, variables); 29 | }, 30 | mutate: (...args) => { 31 | return networkLayer.performMutation(endpoint, ...args); 32 | }, 33 | }; 34 | } 35 | 36 | render() { 37 | const { children } = this.props; 38 | return children; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/components/container.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import invariant from 'invariant'; 3 | 4 | import getDisplayName from '../utils/getDisplayName'; 5 | import shallowEqual from '../utils/shallowEqual'; 6 | 7 | 8 | export default function container(specs) { 9 | return DecoratedComponent => { 10 | const displayName = `AdrenalineContainer(${getDisplayName(DecoratedComponent)})`; 11 | 12 | invariant( 13 | specs !== null && specs !== undefined, 14 | `${displayName} requires configuration.` 15 | ); 16 | 17 | invariant( 18 | typeof specs.query === 'string', 19 | `You have to define 'query' as a string in ${displayName}.` 20 | ); 21 | 22 | invariant( 23 | !specs.variables || typeof specs.variables === 'function', 24 | `You have to define 'variables' as a function in ${displayName}.` 25 | ); 26 | 27 | function mapPropsToVariables(props) { 28 | return !!specs.variables ? specs.variables(props) : {}; 29 | } 30 | 31 | return class extends Component { 32 | static displayName = displayName 33 | static DecoratedComponent = DecoratedComponent 34 | 35 | static contextTypes = { 36 | query: PropTypes.func, 37 | mutate: PropTypes.func, 38 | } 39 | 40 | static getSpecs() { 41 | return specs; 42 | } 43 | 44 | constructor(props, context) { 45 | super(props, context); 46 | 47 | this.state = { 48 | data: null, 49 | isFetching: true, 50 | }; 51 | } 52 | 53 | componentWillMount() { 54 | this.query(); 55 | } 56 | 57 | componentWillReceiveProps(nextProps) { 58 | if (!shallowEqual( 59 | mapPropsToVariables(this.props), 60 | mapPropsToVariables(nextProps) 61 | )) { 62 | this.query(nextProps); 63 | } 64 | } 65 | 66 | query = (props = this.props) => { 67 | const { query } = specs; 68 | const variables = mapPropsToVariables(props); 69 | 70 | this.setState({ isFetching: true }, () => { 71 | this.context.query(query, variables) 72 | .catch(err => { 73 | console.error(err); // eslint-disable-line 74 | }) 75 | .then(data => this.setState({ data, isFetching: false })); 76 | }); 77 | } 78 | 79 | mutate = ({ mutation = '', variables = {}, files = null, invalidate = true }) => { 80 | return this.context.mutate(mutation, variables, files) 81 | .then(() => { 82 | if (invalidate) { 83 | this.query(); 84 | } 85 | }); 86 | } 87 | 88 | render() { 89 | const { data, isFetching } = this.state; 90 | const variables = mapPropsToVariables(this.props); 91 | 92 | const dataOrDefault = !data ? {} : data; 93 | 94 | return ( 95 | 101 | ); 102 | } 103 | }; 104 | }; 105 | } 106 | -------------------------------------------------------------------------------- /src/components/presenter.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import invariant from 'invariant'; 3 | 4 | import getDisplayName from '../utils/getDisplayName'; 5 | import isPlainObject from '../utils/isPlainObject'; 6 | 7 | 8 | export default function presenter(specs) { 9 | return DecoratedComponent => { 10 | const displayName = `AdrenalinePresenter(${getDisplayName(DecoratedComponent)})`; 11 | 12 | invariant( 13 | specs.hasOwnProperty('fragments'), 14 | '%s have not fragments declared', 15 | displayName 16 | ); 17 | 18 | const { fragments } = specs; 19 | 20 | invariant( 21 | isPlainObject(fragments), 22 | 'Fragments have to be declared as object in %s', 23 | displayName 24 | ); 25 | 26 | return class extends Component { 27 | static displayName = displayName; 28 | static DecoratedComponent = DecoratedComponent; 29 | 30 | static getFragment(key) { 31 | invariant( 32 | typeof key === 'string', 33 | 'You cant call getFragment(key: string) without string key in %s', 34 | displayName 35 | ); 36 | 37 | invariant( 38 | fragments.hasOwnProperty(key), 39 | 'Component %s has no fragment %s', 40 | displayName, 41 | key 42 | ); 43 | 44 | return fragments[key] 45 | .replace(/\s+/g, ' ') 46 | .replace('fragment', '...') 47 | .trim(); 48 | } 49 | 50 | render() { 51 | return ( 52 | 53 | ); 54 | } 55 | }; 56 | }; 57 | } 58 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export Adrenaline from './components/Adrenaline'; 2 | export container from './components/container'; 3 | export presenter from './components/presenter'; 4 | -------------------------------------------------------------------------------- /src/network/defaultNetworkLayer.js: -------------------------------------------------------------------------------- 1 | export default { 2 | performQuery(endpoint, query, variables) { 3 | const opts = { 4 | method: 'post', 5 | headers: { 6 | 'Accept': 'application/json', //eslint-disable-line 7 | 'Content-Type': 'application/json', 8 | }, 9 | credentials: 'same-origin', 10 | body: JSON.stringify({ query, variables }), 11 | }; 12 | 13 | return fetch(endpoint, opts) 14 | .then(parseJSON); 15 | }, 16 | 17 | performMutation(endpoint, mutation, variables, files) { 18 | if (!files) { 19 | return fetch(endpoint, { 20 | method: 'post', 21 | headers: { 22 | 'Accept': 'application/json', // eslint-disable-line 23 | 'Content-Type': 'application/json', 24 | }, 25 | credentials: 'same-origin', 26 | body: JSON.stringify({ 27 | query: mutation, 28 | variables, 29 | }), 30 | }).then(parseJSON); 31 | } 32 | 33 | const formData = new FormData(); 34 | formData.append('query', mutation); 35 | formData.append('variables', JSON.stringify(variables)); 36 | if (files) { 37 | for (const filename in files) { 38 | if (files.hasOwnProperty(filename)) { 39 | formData.append(filename, files[filename]); 40 | } 41 | } 42 | } 43 | 44 | return fetch(endpoint, { 45 | method: 'post', 46 | credentials: 'same-origin', 47 | body: formData, 48 | }).then(parseJSON); 49 | }, 50 | }; 51 | 52 | function parseJSON(res) { 53 | if (res.status !== 200) { 54 | throw new Error('Invalid request.'); 55 | } 56 | 57 | return res.json().then(json => json.data); 58 | } 59 | -------------------------------------------------------------------------------- /src/test-utils/expect.js: -------------------------------------------------------------------------------- 1 | import { parse, validate } from 'graphql'; 2 | 3 | export default { 4 | toBeValidAgainst(schema) { 5 | const specs = this.actual.getSpecs(); 6 | const errors = validate(schema, parse(specs.query)); 7 | 8 | if (errors.length === 0) return this; 9 | 10 | throw errors[0]; 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /src/test.js: -------------------------------------------------------------------------------- 1 | import expect from './test-utils/expect'; 2 | 3 | export default { expect }; 4 | -------------------------------------------------------------------------------- /src/utils/__tests__/getDisplayName.spec.js: -------------------------------------------------------------------------------- 1 | import expect from 'expect'; 2 | 3 | import getDisplayName from '../getDisplayName'; 4 | 5 | 6 | describe('utils', () => { 7 | describe('getDisplayName', () => { 8 | it('should return String or Component for empty object', () => { 9 | const actual = [ 10 | { displayName: 'hey' }, 11 | { name: 'ho' }, 12 | {}, 13 | ].map(getDisplayName); 14 | const expected = ['hey', 'ho', 'Component']; 15 | expect(actual).toEqual(expected); 16 | }); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/utils/__tests__/shallowEqual.spec.js: -------------------------------------------------------------------------------- 1 | import expect from 'expect'; 2 | 3 | import shallowEqual from '../shallowEqual'; 4 | 5 | 6 | describe('utils', () => { 7 | describe('shallowEqual', () => { 8 | it('should return true if arguments fields are equal', () => { 9 | expect( 10 | shallowEqual( 11 | { a: 1, b: 2, c: undefined }, 12 | { a: 1, b: 2, c: undefined }, 13 | ) 14 | ).toBe(true); 15 | 16 | expect( 17 | shallowEqual( 18 | { a: 1, b: 2, c: 3 }, 19 | { a: 1, b: 2, c: 3 }, 20 | ) 21 | ).toBe(true); 22 | 23 | const o = {}; 24 | expect( 25 | shallowEqual( 26 | { a: 1, b: 2, c: o }, 27 | { a: 1, b: 2, c: o }, 28 | ) 29 | ).toBe(true); 30 | }); 31 | 32 | it('should return false if first argument has too many keys', () => { 33 | expect( 34 | shallowEqual( 35 | { a: 1, b: 2, c: 3 }, 36 | { a: 1, b: 2 }, 37 | ) 38 | ).toBe(false); 39 | }); 40 | 41 | it('should return false if second argument has too many keys', () => { 42 | expect( 43 | shallowEqual( 44 | { a: 1, b: 2 }, 45 | { a: 1, b: 2, c: 3 }, 46 | ) 47 | ).toBe(false); 48 | }); 49 | 50 | it('should return false if arguments have different keys', () => { 51 | expect( 52 | shallowEqual( 53 | { a: 1, b: 2, c: undefined }, 54 | { a: 1, bb: 2, c: undefined }, 55 | ) 56 | ).toBe(false); 57 | }); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /src/utils/getDisplayName.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | export default function getDisplayName(Component) { 4 | return Component.displayName || Component.name || 'Component'; 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/isPlainObject.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | export default function isPlainObject(obj) { 4 | return obj 5 | ? typeof obj === 'object' 6 | && Object.getPrototypeOf(obj) === Object.prototype 7 | : false; 8 | } 9 | -------------------------------------------------------------------------------- /src/utils/shallowEqual.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | export default function shallowEqual(objA, objB) { 4 | if (objA === objB) { 5 | return true; 6 | } 7 | 8 | const keysA = Object.keys(objA || {}); 9 | const keysB = Object.keys(objB || {}); 10 | 11 | if (keysA.length !== keysB.length) { 12 | return false; 13 | } 14 | 15 | // Test for A's keys different from B. 16 | const hasOwn = Object.prototype.hasOwnProperty; 17 | for (let i = 0; i < keysA.length; i++) { 18 | if (!hasOwn.call(objB, keysA[i]) || 19 | objA[keysA[i]] !== objB[keysA[i]]) { 20 | return false; 21 | } 22 | } 23 | 24 | return true; 25 | } 26 | --------------------------------------------------------------------------------