From e5c64a0b3e90ef340533a6be4e1c8e93c47838e9 Mon Sep 17 00:00:00 2001 From: James Baxley Date: Thu, 14 Apr 2016 22:45:51 -0400 Subject: [PATCH 01/16] initial spec code and one working test --- .gitignore | 32 +++++ .npmignore | 1 + .travis.yml | 15 +++ .vscode/launch.json | 14 +++ .vscode/settings.json | 16 +++ LICENSE | 22 ++++ README.md | 3 + appveyor.yml | 26 ++++ design.md | 57 +++++++++ package.json | 61 +++++++++ src/Provider.tsx | 58 +++++++++ src/connect.tsx | 277 +++++++++++++++++++++++++++++++++++++++++ src/index.ts | 4 + src/utils.ts | 0 test/Provider.tsx | 29 +++++ test/fixtures/setup.js | 18 +++ tsconfig.json | 23 ++++ tslint.json | 142 +++++++++++++++++++++ typings.json | 19 +++ 19 files changed, 817 insertions(+) create mode 100644 .gitignore create mode 100644 .npmignore create mode 100644 .travis.yml create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json create mode 100644 LICENSE create mode 100644 README.md create mode 100644 appveyor.yml create mode 100644 design.md create mode 100644 package.json create mode 100644 src/Provider.tsx create mode 100644 src/connect.tsx create mode 100644 src/index.ts create mode 100644 src/utils.ts create mode 100644 test/Provider.tsx create mode 100644 test/fixtures/setup.js create mode 100644 tsconfig.json create mode 100644 tslint.json create mode 100644 typings.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..734389120b --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# Logs +logs +*.log + +# Runtime data +pids +*.pid +*.seed + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directory +# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git +node_modules + +# don't commit compiled files +lib +test-lib +typings diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000000..33a9488b16 --- /dev/null +++ b/.npmignore @@ -0,0 +1 @@ +example diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000000..be1bae1517 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,15 @@ +language: node_js +node_js: + - "5" + - "4" +install: + - npm install -g coveralls + - npm install + +script: + - npm test + - npm run coverage + - coveralls < ./coverage/lcov.info || true # ignore coveralls error + +# Allow Travis tests to run in containers. +sudo: false diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000000..a16ae77315 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,14 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Test", + "type": "node", + "program": "${workspaceRoot}/node_modules/mocha/bin/_mocha", + "stopOnEntry": false, + "args": ["lib/test/tests.js"], + "cwd": "${workspaceRoot}", + "runtimeExecutable": null + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000000..764a4ee462 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,16 @@ +// Place your settings in this file to overwrite default and user settings. +{ + "editor.tabSize": 2, + "editor.rulers": [100], + "files.trimTrailingWhitespace": true, + "files.insertFinalNewline": true, + "editor.wrappingColumn": 100, + "files.exclude": { + "**/.git": true, + "**/.DS_Store": true, + "node_modules": true, + "test-lib": true, + // "lib": true, + "coverage": true + } +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000..0808d68832 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2015 Ben Newman + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/README.md b/README.md new file mode 100644 index 0000000000..fa85209fc2 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# React Apollo + +> React bindings for the apollo client diff --git a/appveyor.yml b/appveyor.yml new file mode 100644 index 0000000000..2c4eaf0af6 --- /dev/null +++ b/appveyor.yml @@ -0,0 +1,26 @@ +# Test against this version of Node.js +environment: + matrix: + # node.js + - nodejs_version: "5" + - nodejs_version: "4" + +# Install scripts. (runs after repo cloning) +install: + # Get the latest stable version of Node.js or io.js + - ps: Install-Product node $env:nodejs_version + # install modules + - npm install + +# Post-install test scripts. +test_script: + # run tests + - npm test + +# artifacts: +# - path: ./junit/xunit.xml +# - path: ./xunit.xml + +# nothing to compile in this project +build: off +deploy: off diff --git a/design.md b/design.md new file mode 100644 index 0000000000..425b3a7756 --- /dev/null +++ b/design.md @@ -0,0 +1,57 @@ +# Design principles of the Apollo Client + +If we are building a client-side GraphQL client and cache, we should have some goals that carve out our part of that space. These are the competitive advantages we believe this library will have over others that implement a similar set of functionality. + +## Principles + +The Apollo Client should be... + +1. Functional - this library should bring benefits to an application's developers and end users to achieve performance, usability, and simplicity. It should have more features than [Lokka](https://github.com/kadirahq/lokka) but less than [Relay](https://github.com/facebook/relay). +1. Transparent - a developer should be able to keep everything the Apollo Client is doing in their mind at once. They don't necessarily need to understand every part of the implementation, but nothing it's doing should be a surprise. This principle should take precedence over fine-grained performance optimizations. +1. Standalone - the published library should not impose any specific build or runtime environment, view framework, router, or development philosophies other than JavaScript, NPM, or GraphQL. When you install it via NPM in any NPM-compatible development environment, the batteries are included. Anything that isn't included, like certain polyfills, is clearly documented. +1. Compatible - the Apollo Client core should be compatible with as many GraphQL schemas, transports, and execution models as possible. There might be optimizations that rely on specific server-side features, but it should "just work" even in the absence of those features. +1. Usable - given the above, developer experience should be a priority. The API for the application developer should have a simple mental model, a minimal surface area, and be clearly documented. + +## Implementation + +I think the principles above naturally lead to the following constraints on the implementation: + +### Necessary features + +I think there is a "minimum viable" set of features for a good GraphQL client. Almost all GraphQL clients that aren't Relay don't have some of these features, and the necessity to have them, even when they don't have many other requirements, is what drives people to use Relay which often brings more functionality and complexity than they need for their application. Bringing us to [this graph from React Conf](https://www.dropbox.com/s/kppd4kdz40h96kj/Screenshot%202016-03-19%2016.40.19.png?dl=0) ([full slides here](https://github.com/jaredly/reactconf)). + +Based on talking to some developers, I believe that list includes, in no particular order: + +- Optimistic UI for mutations +- A cache so that you don't refetch data you already have +- The ability to manually refetch data when you know it has changed +- The ability to preload data you might need later +- Minimal server roundtrips to render the initial UI +- Query aggregation from your UI tree +- Basic handling of pagination, most critically being able to fetch a new page of items when you already have some + +The implementation process will determine the order in which these are completed. + +### Stateless, well-documented store format + +All state of the GraphQL cache should be kept in a single immutable state object (referred to as the "store"), and every operation on the store should be implemented as a function from the previous store object to a new one. The store format should be easily understood and inspected by the application developer, rather than an implementation detail of the library. + +This will have many benefits compared to other approaches: + +1. Simple debugging/testing both of the Apollo client itself and apps built with it, by making it possible to analyze the store contents directly and step through the different states +2. Trivial optimistic UI using time-traveling and reordering of actions taken on the store +3. Easy integration of extensions/middlewares by sharing a common data interchange format + +To enable this, we need to have clear documentation about the format of this store object, so that people can write extensions around it and be sure that they will remain compatible. + +### Lowest-common-denominator APIs between modules + +APIs between the different parts of the library should be in simple, standard, easy-to-understand formats. We should avoid creating Apollo-specific representations of queries and data, and stick to the tools available - GraphQL strings, the standard GraphQL AST, selection sets, etc. + +If we do invent new data interchange APIs, they need to be clearly documented, have a good and documented reason for existing, and be stable so that plugins and extensions can use them. + +### Simple modules, each with a clear purpose + +There are many utilities that any smart GraphQL cache will need around query diffing, reading a cache, etc. These should be written in a way that it would make sense to use them in any GraphQL client. In short, this is a set of libraries, not a framework. + +Each module should have minimal dependencies on the runtime environment. For example, the network layer can assume HTTP, but not any other part. diff --git a/package.json b/package.json new file mode 100644 index 0000000000..5dc8d2d1f8 --- /dev/null +++ b/package.json @@ -0,0 +1,61 @@ +{ + "name": "react-apollo", + "version": "0.0.1", + "description": "React bindings for the apollo data stack", + "main": "./lib/src/index.js", + "scripts": { + "pretest": "npm run compile", + "test": "mocha --require ./test/fixtures/setup.js --reporter spec --full-trace --recursive ./lib/test", + "posttest": "npm run lint", + "compile": "tsc", + "watch": "tsc -w", + "prepublish": "npm run compile", + "lint": "tslint src/*.ts && tslint test/*.ts", + "coverage": "istanbul cover ./node_modules/mocha/bin/_mocha -- --reporter dot --full-trace lib/test/tests.js", + "postcoverage": "remap-istanbul --input coverage/coverage.json --type lcovonly --output coverage/lcov.info" + }, + "repository": { + "type": "git", + "url": "apollostack/react-apollo" + }, + "keywords": [ + "ecmascript", + "es2015", + "jsnext", + "javascript", + "relay", + "npm", + "react" + ], + "author": "James Baxley ", + "license": "MIT", + "peerDependencies": { + "react": "^0.14.0 || ^15.0.0-0", + "redux": "^2.0.0 || ^3.0.0" + }, + "devDependencies": { + "chai": "^3.5.0", + "chai-as-promised": "^5.2.0", + "chai-enzyme": "^0.4.2", + "cheerio": "^0.20.0", + "enzyme": "^2.2.0", + "istanbul": "^0.4.2", + "jsdom": "^8.3.1", + "mocha": "^2.3.3", + "react-addons-test-utils": "^0.14.0 || ^15.0.0-0", + "react-dom": "^0.14.8", + "remap-istanbul": "^0.5.1", + "source-map-support": "^0.4.0", + "tslint": "^3.6.0", + "typescript": "^1.8.9", + "typescript-require": "^0.2.9-1", + "typings": "^0.7.9" + }, + "dependencies": { + "hoist-non-react-statics": "^1.0.5", + "lodash.isequal": "^4.1.1", + "lodash.isobject": "^3.0.2", + "object-assign": "^4.0.1", + "react-redux": "^4.4.4" + } +} diff --git a/src/Provider.tsx b/src/Provider.tsx new file mode 100644 index 0000000000..36910b70b4 --- /dev/null +++ b/src/Provider.tsx @@ -0,0 +1,58 @@ +/// + +import { + Component, + PropTypes, + Children, +} from 'react'; + +import { + Store +} from 'redux'; + +// XXX add in type defs from apollo-client +// import ApolloClient from "apollo-client" + +export declare interface ProviderProps { + store: Store; + client: any; +} + +export default class Provider extends Component { + public store: Store; + // public client: ApolloClient; + public client: any; + + static propTypes = { + store: PropTypes.shape({ + subscribe: PropTypes.func.isRequired, + dispatch: PropTypes.func.isRequired, + getState: PropTypes.func.isRequired + }), + client: PropTypes.object.isRequired, + children: PropTypes.element.isRequired, + } + + static childContextTypes = { + store: PropTypes.object.isRequired, + client: PropTypes.object.isRequired, + } + + constructor(props, context) { + super(props, context); + this.client = props.client; + this.store = props.store + } + + getChildContext() { + return { + store: this.store, + client: this.client, + } + } + + render(){ + const { children } = this.props + return Children.only(children) + } +} \ No newline at end of file diff --git a/src/connect.tsx b/src/connect.tsx new file mode 100644 index 0000000000..46b5ddc876 --- /dev/null +++ b/src/connect.tsx @@ -0,0 +1,277 @@ +/// + +import { + Component, + createElement, +} from 'react'; + +// XXX setup type definitions for individual lodash libs +// import isObject from 'lodash.isobject'; +// import isEqual from 'lodash.isequal'; +import { + isEqual, + isObject, +} from 'lodash'; + +// modules don't export ES6 modules +import invariant = require('invariant'); +import assign = require('object-assign'); + +import { + IMapStateToProps, + IMapDispatchToProps, + IConnectOptions, + connect as ReactReduxConnect, +} from 'react-redux'; + +import { + Store +} from 'redux'; + +export declare interface MapQueriesToPropsOptions{ + watchQuery(opts: any): any; // WatchQueryHandle + ownProps: any; + state: any; +}; + +export declare interface MapMutationsToPropsOptions{ + mutate(opts: any): any; // MutationHandle + onPostReply(any): any; // MutationHandle + ownProps: any; + state: any; +}; + +export declare interface ConnectOptions { + // mapStateToProps, mapDispatchToProps, mergeProps, options + mapStateToProps: IMapStateToProps; + mapDispatchToProps: IMapDispatchToProps, + mergeProps(stateProps: any, dispatchProps: any, ownProps: any): any; + options: IConnectOptions; + mapQueriesToProps(opts: MapQueriesToPropsOptions): any; // WatchQueryHandle + mapMutationsToProps(opts: MapMutationsToPropsOptions): any; // Mutation Handle +}; + +const defaultMapQueriesToProps = opts => ({}); +const defaultMapMutationsToProps = opts => ({}); +const defaultQueryData = { + loading: true, + error: null, + result: null, +}; + +function getDisplayName(WrappedComponent) { + return WrappedComponent.displayName || WrappedComponent.name || 'Component'; +} + +// Helps track hot reloading. +let nextVersion = 0; + +export default function connect(opts: ConnectOptions) { + let { mapQueriesToProps, mapMutationsToProps } = opts; + + // clean up the options for passing to redux + delete opts.mapQueriesToProps; + delete opts.mapMutationsToProps; + + /* + + This connect is a wrapper around react-redux's connect. If called + without any apollo specific actions, we can pass the props straight to + react-redux's connect and call it quits + + */ + if (!mapQueriesToProps && !mapMutationsToProps) { + const { mapStateToProps, mapDispatchToProps, mergeProps, options } = opts; + return function passReactReduxArgumentsPlain(WrappedComponent) { + return ReactReduxConnect( + mapStateToProps, + mapDispatchToProps, + mergeProps, + options + )(WrappedComponent); + } + } + + /* + + mapQueriesToProps: + + This method returns a object in the form of { [string]: WatchQueryHandle }. Each + key will be mapped to the props passed to the wrapped component. The resulting prop will + be an object with the following keys: + + { + loading: boolean, + error: Error, + result: GraphQLResult, + } + + */ + mapQueriesToProps = mapQueriesToProps ? mapQueriesToProps : defaultMapQueriesToProps; + + /* + + mapMutationsToProps + + */ + mapMutationsToProps = mapMutationsToProps ? mapMutationsToProps : defaultMapMutationsToProps; + + // Helps track hot reloading. + const version = nextVersion++; + + return function wrapWithApolloComponent(WrappedComponent) { + const apolloConnectDisplayName = `Apollo(Connect(${getDisplayName(WrappedComponent)}))`; + + class ApolloConnect extends Component { + public version: number; + public store: Store;; + public client: any; // apollo client + public state: any; // redux state + public props: any; // passed props + public data: any; // apollo data + public queryHandles: any; + public haveOwnPropsChanged: boolean; + public hasQueryDataChanged: boolean; + public hasMutationDataChanged: boolean; + public renderedElement: any; + + static displayName = apolloConnectDisplayName + static WrappedComponent = WrappedComponent + + constructor(props, context) { + super(props, context); + this.version = version; + this.store = props.store || context.store; + this.client = props.client || context.client; + + invariant(this.client, + `Could not find "client" in either the context or ` + + `props of "${apolloConnectDisplayName}". ` + + `Either wrap the root component in a , ` + + `or explicitly pass "client" as a prop to "${apolloConnectDisplayName}".` + ); + + const storeState = this.store.getState(); + this.state = { storeState }; + + this.data = {}; + } + + componentWillMount(){ + // + } + + // best practice says make external requests in `componentDidMount` as to + // not block rendering + componentDidMount(){ + const { props, state } = this; + this.subscribeToAllQueries(props, state); + } + + componentWillRecieveProps(nextProps, nextState){ + // we got new props, we need to unsubscribe and re-watch all handles + // with the new data + // XXX determine if any of this data is actually used somehow + // to avoid rebinding queries if nothing has changed + if (!isEqual(this.props, nextProps) || !isEqual(this.state, nextState)) { + this.unsubcribeAllQueries(); + this.subscribeToAllQueries(nextProps, nextState); + } + } + + shouldComponentUpdate(){ + return this.haveOwnPropsChanged || this.hasQueryDataChanged || this.hasMutationDataChanged; + } + + componentWillUnmount(){ + this.unsubcribeAllQueries(); + } + + subscribeToAllQueries(props: any, state: any){ + const { watchQuery } = this.client; + + const queryHandles = mapQueriesToProps({ + watchQuery, + state, + ownProps: props, + }); + + if (isObject && Object.keys(queryHandles).length) { + this.queryHandles = queryHandles; + + for (const key in queryHandles){ + const handle = queryHandles[key]; + + // bind key to state for updating + this.data[key] = defaultQueryData; + + this.handleQueryData(handle, key); + } + } + } + + unsubcribeAllQueries(){ + if (this.queryHandles) { + for (const key in this.queryHandles) { + this.queryHandles[key].stop(); + } + } + } + + handleQueryData(handle: any, key: string){ + // bind each handle to updating and rerendering when data + // has been recieved + handle.onResult(({ error, data }) => { + this.data[key] = { + loading: false, + result: data, + error, + }; + + this.hasQueryDataChanged = true; + + // update state to latest of redux store + this.setState(this.store.getState()); + }); + } + + render(){ + const { + haveOwnPropsChanged, + hasQueryDataChanged, + hasMutationDataChanged, + renderedElement, + data, + state, + props, + } = this; + + this.haveOwnPropsChanged = false; + this.hasQueryDataChanged = false; + this.hasMutationDataChanged = false; + + const mergedPropsAndData = assign(this.props, this.data); + + if (!hasQueryDataChanged && !hasMutationDataChanged && renderedElement) { + return renderedElement; + } + + this.renderedElement = createElement(WrappedComponent, mergedPropsAndData); + + return this.renderedElement; + } + + } + + // apply react-redux args from original args + const { mapStateToProps, mapDispatchToProps, mergeProps, options } = opts; + return ReactReduxConnect( + mapStateToProps, + mapDispatchToProps, + mergeProps, + options + )(ApolloConnect); + + } + +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000000..eed507c48e --- /dev/null +++ b/src/index.ts @@ -0,0 +1,4 @@ +import Provider from './Provider'; +import connect from './connect'; + +export { Provider, connect }; diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/Provider.tsx b/test/Provider.tsx new file mode 100644 index 0000000000..a70c8ece38 --- /dev/null +++ b/test/Provider.tsx @@ -0,0 +1,29 @@ +/// + +import * as React from 'react'; +import * as chai from 'chai'; +import { shallow, ShallowWrapper } from 'enzyme'; +import { createStore } from 'redux'; + +declare function require(name: string); +const chaiEnzyme = require('chai-enzyme'); +chai.use(chaiEnzyme()) // Note the invocation at the end +const { expect, assert } = chai; + +import Provider from '../src/Provider'; + +describe(' Component', () => { + + it('should render children components', () => { + const store = createStore(() => ({})) + + const wrapper = shallow( + +
+ + ); + + expect(wrapper.contains(
)).to.equal(true); + }); + +}); \ No newline at end of file diff --git a/test/fixtures/setup.js b/test/fixtures/setup.js new file mode 100644 index 0000000000..9338e05e25 --- /dev/null +++ b/test/fixtures/setup.js @@ -0,0 +1,18 @@ +/* setup.js */ +// github.com/airbnb/enzyme/blob/699ec7e39560a68c198ecf80b59d177d003fc869/docs/guides/jsdom.md +var jsdom = require('jsdom').jsdom; + +var exposedProperties = ['window', 'navigator', 'document']; + +global.document = jsdom(''); +global.window = document.defaultView; +Object.keys(document.defaultView).forEach((property) => { + if (typeof global[property] === 'undefined') { + exposedProperties.push(property); + global[property] = document.defaultView[property]; + } +}); + +global.navigator = { + userAgent: 'node.js' +}; \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000000..6464c685d8 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "es5", + "module": "commonjs", + "moduleResolution": "node", + "sourceMap": true, + "declaration": true, + "noImplicitAny": false, + "rootDir": ".", + "outDir": "lib", + "allowSyntheticDefaultImports": true, + "pretty": true, + "removeComments": true, + "jsx": "react" + }, + "exclude": [ + "typings", + "node_modules", + "dist", + "lib", + "test/fixtures" + ] +} diff --git a/tslint.json b/tslint.json new file mode 100644 index 0000000000..5736fb21f8 --- /dev/null +++ b/tslint.json @@ -0,0 +1,142 @@ +{ + "rules": { + "align": [ + false, + "parameters", + "arguments", + "statements" + ], + "ban": false, + "class-name": true, + "curly": true, + "eofline": true, + "forin": true, + "indent": [ + true, + "spaces" + ], + "interface-name": false, + "jsdoc-format": true, + "label-position": true, + "label-undefined": true, + "max-line-length": [ + true, + 140 + ], + "member-access": true, + "member-ordering": [ + true, + "public-before-private", + "static-before-instance", + "variables-before-functions" + ], + "no-any": false, + "no-arg": true, + "no-bitwise": true, + "no-conditional-assignment": true, + "no-consecutive-blank-lines": false, + "no-console": [ + true, + "log", + "debug", + "info", + "time", + "timeEnd", + "trace" + ], + "no-construct": true, + "no-constructor-vars": true, + "no-debugger": true, + "no-duplicate-key": true, + "no-duplicate-variable": true, + "no-empty": true, + "no-eval": true, + "no-inferrable-types": false, + "no-internal-module": true, + "no-null-keyword": false, + "no-require-imports": true, + "no-shadowed-variable": true, + "no-switch-case-fall-through": true, + "no-trailing-whitespace": true, + "no-unreachable": true, + "no-unused-expression": true, + "no-unused-variable": true, + "no-use-before-declare": true, + "no-var-keyword": true, + "no-var-requires": true, + "object-literal-sort-keys": false, + "one-line": [ + true, + "check-open-brace", + "check-catch", + "check-else", + "check-finally", + "check-whitespace" + ], + "quotemark": [ + true, + "single", + "avoid-escape" + ], + "radix": true, + "semicolon": [ + true, + "always" + ], + "switch-default": true, + "trailing-comma": [ + true, + { + "multiline": "always", + "singleline": "never" + } + ], + "triple-equals": [ + true, + "allow-null-check" + ], + "typedef": [ + false, + "call-signature", + "parameter", + "arrow-parameter", + "property-declaration", + "variable-declaration", + "member-variable-declaration" + ], + "typedef-whitespace": [ + true, + { + "call-signature": "nospace", + "index-signature": "nospace", + "parameter": "nospace", + "property-declaration": "nospace", + "variable-declaration": "nospace" + }, + { + "call-signature": "space", + "index-signature": "space", + "parameter": "space", + "property-declaration": "space", + "variable-declaration": "space" + } + ], + "use-strict": [ + false + ], + "variable-name": [ + true, + "check-format", + "allow-leading-underscore", + "ban-keywords" + ], + "whitespace": [ + true, + "check-branch", + "check-decl", + "check-operator", + "check-separator", + "check-type" + ] + } +} diff --git a/typings.json b/typings.json new file mode 100644 index 0000000000..5e9950d880 --- /dev/null +++ b/typings.json @@ -0,0 +1,19 @@ +{ + "ambientDependencies": { + "enzyme": "registry:dt/enzyme#1.2.0+20160324040416", + "mocha": "registry:dt/mocha#2.2.5+20160317120654", + "react": "registry:dt/react#0.14.0+20160319053454", + "react-dom": "registry:dt/react-dom#0.14.0+20160316155526" + }, + "devDependencies": { + "chai": "registry:npm/chai#3.5.0+20160328084239", + "chai-enzyme": "registry:npm/chai-enzyme#0.4.0+20160310114720", + "sinon": "registry:npm/sinon#1.16.0+20160309002336" + }, + "dependencies": { + "invariant": "registry:npm/invariant#2.0.0+20160211003958", + "lodash": "registry:npm/lodash#4.0.0+20160412191219", + "object-assign": "registry:npm/object-assign#4.0.1+20160301180549", + "react-redux": "registry:npm/react-redux#4.4.0+20160207114942" + } +} From 9eb2fb66b927d2592dcf94f3e67abb665b12d17e Mon Sep 17 00:00:00 2001 From: James Baxley Date: Thu, 14 Apr 2016 23:54:00 -0400 Subject: [PATCH 02/16] linting and working on more tests --- package.json | 4 +-- src/Provider.tsx | 28 ++++++++--------- src/connect.tsx | 80 ++++++++++++++++++++++++++--------------------- test/Provider.tsx | 57 ++++++++++++++++++++++++++++----- tslint.json | 6 ++-- typings.json | 1 + 6 files changed, 114 insertions(+), 62 deletions(-) diff --git a/package.json b/package.json index 5dc8d2d1f8..ea16f06e7a 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "compile": "tsc", "watch": "tsc -w", "prepublish": "npm run compile", - "lint": "tslint src/*.ts && tslint test/*.ts", + "lint": "tslint src/*.ts* && tslint test/*.ts*", "coverage": "istanbul cover ./node_modules/mocha/bin/_mocha -- --reporter dot --full-trace lib/test/tests.js", "postcoverage": "remap-istanbul --input coverage/coverage.json --type lcovonly --output coverage/lcov.info" }, @@ -42,7 +42,7 @@ "istanbul": "^0.4.2", "jsdom": "^8.3.1", "mocha": "^2.3.3", - "react-addons-test-utils": "^0.14.0 || ^15.0.0-0", + "react-addons-test-utils": "^15.0.1", "react-dom": "^0.14.8", "remap-istanbul": "^0.5.1", "source-map-support": "^0.4.0", diff --git a/src/Provider.tsx b/src/Provider.tsx index 36910b70b4..b22212e46a 100644 --- a/src/Provider.tsx +++ b/src/Provider.tsx @@ -7,7 +7,7 @@ import { } from 'react'; import { - Store + Store, } from 'redux'; // XXX add in type defs from apollo-client @@ -19,40 +19,40 @@ export declare interface ProviderProps { } export default class Provider extends Component { - public store: Store; - // public client: ApolloClient; - public client: any; - static propTypes = { store: PropTypes.shape({ subscribe: PropTypes.func.isRequired, dispatch: PropTypes.func.isRequired, - getState: PropTypes.func.isRequired + getState: PropTypes.func.isRequired, }), client: PropTypes.object.isRequired, children: PropTypes.element.isRequired, - } + }; static childContextTypes = { store: PropTypes.object.isRequired, client: PropTypes.object.isRequired, - } + }; + + public store: Store; + // public client: ApolloClient; + public client: any; constructor(props, context) { super(props, context); this.client = props.client; - this.store = props.store + this.store = props.store; } getChildContext() { return { store: this.store, client: this.client, - } + }; } - render(){ - const { children } = this.props - return Children.only(children) + render() { + const { children } = this.props; + return Children.only(children); } -} \ No newline at end of file +}; diff --git a/src/connect.tsx b/src/connect.tsx index 46b5ddc876..6cf6753cc4 100644 --- a/src/connect.tsx +++ b/src/connect.tsx @@ -25,34 +25,33 @@ import { } from 'react-redux'; import { - Store + Store, } from 'redux'; -export declare interface MapQueriesToPropsOptions{ - watchQuery(opts: any): any; // WatchQueryHandle +export declare interface MapQueriesToPropsOptions { ownProps: any; state: any; + watchQuery(opts: any): any; // WatchQueryHandle }; -export declare interface MapMutationsToPropsOptions{ - mutate(opts: any): any; // MutationHandle - onPostReply(any): any; // MutationHandle +export declare interface MapMutationsToPropsOptions { ownProps: any; state: any; + mutate(opts: any): any; // MutationHandle + onPostReply(raw: any): any; // MutationHandle }; export declare interface ConnectOptions { - // mapStateToProps, mapDispatchToProps, mergeProps, options mapStateToProps: IMapStateToProps; - mapDispatchToProps: IMapDispatchToProps, - mergeProps(stateProps: any, dispatchProps: any, ownProps: any): any; + mapDispatchToProps: IMapDispatchToProps; options: IConnectOptions; + mergeProps(stateProps: any, dispatchProps: any, ownProps: any): any; mapQueriesToProps(opts: MapQueriesToPropsOptions): any; // WatchQueryHandle mapMutationsToProps(opts: MapMutationsToPropsOptions): any; // Mutation Handle }; -const defaultMapQueriesToProps = opts => ({}); -const defaultMapMutationsToProps = opts => ({}); +const defaultMapQueriesToProps = opts => ({ }); +const defaultMapMutationsToProps = opts => ({ }); const defaultQueryData = { loading: true, error: null, @@ -89,8 +88,8 @@ export default function connect(opts: ConnectOptions) { mergeProps, options )(WrappedComponent); - } - } + }; + }; /* @@ -123,8 +122,11 @@ export default function connect(opts: ConnectOptions) { const apolloConnectDisplayName = `Apollo(Connect(${getDisplayName(WrappedComponent)}))`; class ApolloConnect extends Component { + static displayName = apolloConnectDisplayName; + static WrappedComponent = WrappedComponent; + public version: number; - public store: Store;; + public store: Store; public client: any; // apollo client public state: any; // redux state public props: any; // passed props @@ -135,9 +137,6 @@ export default function connect(opts: ConnectOptions) { public hasMutationDataChanged: boolean; public renderedElement: any; - static displayName = apolloConnectDisplayName - static WrappedComponent = WrappedComponent - constructor(props, context) { super(props, context); this.version = version; @@ -157,18 +156,18 @@ export default function connect(opts: ConnectOptions) { this.data = {}; } - componentWillMount(){ - // - } + // componentWillMount(){ + + // } // best practice says make external requests in `componentDidMount` as to // not block rendering - componentDidMount(){ + componentDidMount() { const { props, state } = this; this.subscribeToAllQueries(props, state); } - componentWillRecieveProps(nextProps, nextState){ + componentWillRecieveProps(nextProps, nextState) { // we got new props, we need to unsubscribe and re-watch all handles // with the new data // XXX determine if any of this data is actually used somehow @@ -179,15 +178,15 @@ export default function connect(opts: ConnectOptions) { } } - shouldComponentUpdate(){ + shouldComponentUpdate() { return this.haveOwnPropsChanged || this.hasQueryDataChanged || this.hasMutationDataChanged; } - componentWillUnmount(){ + componentWillUnmount() { this.unsubcribeAllQueries(); } - subscribeToAllQueries(props: any, state: any){ + subscribeToAllQueries(props: any, state: any) { const { watchQuery } = this.client; const queryHandles = mapQueriesToProps({ @@ -199,7 +198,11 @@ export default function connect(opts: ConnectOptions) { if (isObject && Object.keys(queryHandles).length) { this.queryHandles = queryHandles; - for (const key in queryHandles){ + for (const key in queryHandles) { + if (!queryHandles.hasOwnProperty(key)) { + continue; + } + const handle = queryHandles[key]; // bind key to state for updating @@ -210,15 +213,18 @@ export default function connect(opts: ConnectOptions) { } } - unsubcribeAllQueries(){ + unsubcribeAllQueries() { if (this.queryHandles) { for (const key in this.queryHandles) { + if (!this.queryHandles.hasOwnProperty(key)) { + continue; + } this.queryHandles[key].stop(); } } } - handleQueryData(handle: any, key: string){ + handleQueryData(handle: any, key: string) { // bind each handle to updating and rerendering when data // has been recieved handle.onResult(({ error, data }) => { @@ -235,15 +241,12 @@ export default function connect(opts: ConnectOptions) { }); } - render(){ + render() { const { haveOwnPropsChanged, hasQueryDataChanged, hasMutationDataChanged, renderedElement, - data, - state, - props, } = this; this.haveOwnPropsChanged = false; @@ -252,7 +255,12 @@ export default function connect(opts: ConnectOptions) { const mergedPropsAndData = assign(this.props, this.data); - if (!hasQueryDataChanged && !hasMutationDataChanged && renderedElement) { + if ( + !haveOwnPropsChanged && + !hasQueryDataChanged && + !hasMutationDataChanged && + renderedElement + ) { return renderedElement; } @@ -272,6 +280,8 @@ export default function connect(opts: ConnectOptions) { options )(ApolloConnect); - } + }; + +}; + -} \ No newline at end of file diff --git a/test/Provider.tsx b/test/Provider.tsx index a70c8ece38..6837389b22 100644 --- a/test/Provider.tsx +++ b/test/Provider.tsx @@ -2,28 +2,69 @@ import * as React from 'react'; import * as chai from 'chai'; -import { shallow, ShallowWrapper } from 'enzyme'; +import { shallow } from 'enzyme'; +// import * as TestUtils from 'react-addons-test-utils'; import { createStore } from 'redux'; declare function require(name: string); -const chaiEnzyme = require('chai-enzyme'); -chai.use(chaiEnzyme()) // Note the invocation at the end -const { expect, assert } = chai; +import chaiEnzyme = require('chai-enzyme'); + +chai.use(chaiEnzyme()); // Note the invocation at the end +const { expect } = chai; import Provider from '../src/Provider'; describe(' Component', () => { + class Child extends React.Component { + render() { + return
; + } + }; + + // XXX why isn't this working with TestUtils typing? + // Child.contextType = { + // store: React.PropTypes.object.isRequired, + // client: React.PropTypes.object.isRequired, + // }; + it('should render children components', () => { - const store = createStore(() => ({})) + const store = createStore(() => ({})); const wrapper = shallow( -
+
); - expect(wrapper.contains(
)).to.equal(true); + expect(wrapper.contains(
)).to.equal(true); }); -}); \ No newline at end of file + it('should throw if rendered without a child component', () => { + const store = createStore(() => ({})); + + try { + shallow( + + ); + } catch (e) { + expect(e).to.be.instanceof(Error); + } + + }); + + // it('should add the store to the child context', () => { + // const store = createStore(() => ({})); + + // const tree = TestUtils.renderIntoDocument( + // + // + // + // ) as React.Component; + + // const child = TestUtils.findRenderedComponentWithType(tree, Child as ComponentClass); + // expect(child.context.store).to.deep.equal(store); + + // }); + +}); diff --git a/tslint.json b/tslint.json index 5736fb21f8..f125713739 100644 --- a/tslint.json +++ b/tslint.json @@ -23,7 +23,7 @@ true, 140 ], - "member-access": true, + "member-access": false, "member-ordering": [ true, "public-before-private", @@ -54,7 +54,7 @@ "no-inferrable-types": false, "no-internal-module": true, "no-null-keyword": false, - "no-require-imports": true, + "no-require-imports": false, "no-shadowed-variable": true, "no-switch-case-fall-through": true, "no-trailing-whitespace": true, @@ -125,7 +125,7 @@ false ], "variable-name": [ - true, + false, "check-format", "allow-leading-underscore", "ban-keywords" diff --git a/typings.json b/typings.json index 5e9950d880..e27e7d0c85 100644 --- a/typings.json +++ b/typings.json @@ -3,6 +3,7 @@ "enzyme": "registry:dt/enzyme#1.2.0+20160324040416", "mocha": "registry:dt/mocha#2.2.5+20160317120654", "react": "registry:dt/react#0.14.0+20160319053454", + "react-addons-test-utils": "registry:dt/react-addons-test-utils#0.14.0+20160316155526", "react-dom": "registry:dt/react-dom#0.14.0+20160316155526" }, "devDependencies": { From ccc7c6f070d3cc2d5d73e37173f9c2a146ceba4a Mon Sep 17 00:00:00 2001 From: James Baxley Date: Fri, 15 Apr 2016 02:38:17 -0400 Subject: [PATCH 03/16] its wooorrrrrkkkiiinnngggg --- .vscode/settings.json | 4 +- package.json | 5 + src/Provider.tsx | 8 +- src/connect.tsx | 43 +++--- test/Provider.tsx | 26 ++-- test/connect.tsx | 328 ++++++++++++++++++++++++++++++++++++++++++ tsconfig.json | 1 + typings.json | 7 +- 8 files changed, 386 insertions(+), 36 deletions(-) create mode 100644 test/connect.tsx diff --git a/.vscode/settings.json b/.vscode/settings.json index 764a4ee462..68994d6ebf 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,9 +8,9 @@ "files.exclude": { "**/.git": true, "**/.DS_Store": true, - "node_modules": true, + // "node_modules": true, "test-lib": true, - // "lib": true, + "lib": true, "coverage": true } } diff --git a/package.json b/package.json index 9eea826dc9..e07e6163bc 100644 --- a/package.json +++ b/package.json @@ -34,18 +34,23 @@ "redux": "^2.0.0 || ^3.0.0" }, "devDependencies": { + "apollo-client": "0.0.5", "chai": "^3.5.0", "chai-as-promised": "^5.2.0", "chai-enzyme": "^0.4.2", "cheerio": "^0.20.0", "enzyme": "^2.2.0", + "graphql": "^0.5.0", "istanbul": "^0.4.2", "jsdom": "^8.3.1", "mocha": "^2.3.3", + "react": "^15.0.1", "react-addons-test-utils": "^15.0.1", "react-dom": "^0.14.8", + "redux": "^3.4.0", "remap-istanbul": "^0.5.1", "source-map-support": "^0.4.0", + "swapi-graphql": "0.0.4", "tslint": "^3.6.0", "typescript": "^1.8.9", "typescript-require": "^0.2.9-1", diff --git a/src/Provider.tsx b/src/Provider.tsx index b22212e46a..f814b35742 100644 --- a/src/Provider.tsx +++ b/src/Provider.tsx @@ -10,12 +10,11 @@ import { Store, } from 'redux'; -// XXX add in type defs from apollo-client -// import ApolloClient from "apollo-client" +import ApolloClient from 'apollo-client'; export declare interface ProviderProps { store: Store; - client: any; + client: ApolloClient; } export default class Provider extends Component { @@ -35,8 +34,7 @@ export default class Provider extends Component { }; public store: Store; - // public client: ApolloClient; - public client: any; + public client: ApolloClient; constructor(props, context) { super(props, context); diff --git a/src/connect.tsx b/src/connect.tsx index 6cf6753cc4..7c6e0bd801 100644 --- a/src/connect.tsx +++ b/src/connect.tsx @@ -3,6 +3,7 @@ import { Component, createElement, + PropTypes, } from 'react'; // XXX setup type definitions for individual lodash libs @@ -42,12 +43,12 @@ export declare interface MapMutationsToPropsOptions { }; export declare interface ConnectOptions { - mapStateToProps: IMapStateToProps; - mapDispatchToProps: IMapDispatchToProps; - options: IConnectOptions; - mergeProps(stateProps: any, dispatchProps: any, ownProps: any): any; - mapQueriesToProps(opts: MapQueriesToPropsOptions): any; // WatchQueryHandle - mapMutationsToProps(opts: MapMutationsToPropsOptions): any; // Mutation Handle + mapStateToProps?: IMapStateToProps; + mapDispatchToProps?: IMapDispatchToProps; + options?: IConnectOptions; + mergeProps?(stateProps: any, dispatchProps: any, ownProps: any): any; + mapQueriesToProps?(opts: MapQueriesToPropsOptions): any; // WatchQueryHandle + mapMutationsToProps?(opts: MapMutationsToPropsOptions): any; // Mutation Handle }; const defaultMapQueriesToProps = opts => ({ }); @@ -65,7 +66,12 @@ function getDisplayName(WrappedComponent) { // Helps track hot reloading. let nextVersion = 0; -export default function connect(opts: ConnectOptions) { +export default function connect(opts?: ConnectOptions) { + + if (!opts) { + opts = {}; + } + let { mapQueriesToProps, mapMutationsToProps } = opts; // clean up the options for passing to redux @@ -124,6 +130,10 @@ export default function connect(opts: ConnectOptions) { class ApolloConnect extends Component { static displayName = apolloConnectDisplayName; static WrappedComponent = WrappedComponent; + static contextTypes = { + store: PropTypes.object.isRequired, + client: PropTypes.object.isRequired, + }; public version: number; public store: Store; @@ -156,18 +166,18 @@ export default function connect(opts: ConnectOptions) { this.data = {}; } - // componentWillMount(){ - - // } - - // best practice says make external requests in `componentDidMount` as to - // not block rendering - componentDidMount() { + componentWillMount() { const { props, state } = this; this.subscribeToAllQueries(props, state); } - componentWillRecieveProps(nextProps, nextState) { + // // best practice says make external requests in `componentDidMount` as to + // // not block rendering + // componentDidMount() { + + // } + + componentWillReceiveProps(nextProps, nextState) { // we got new props, we need to unsubscribe and re-watch all handles // with the new data // XXX determine if any of this data is actually used somehow @@ -253,7 +263,7 @@ export default function connect(opts: ConnectOptions) { this.hasQueryDataChanged = false; this.hasMutationDataChanged = false; - const mergedPropsAndData = assign(this.props, this.data); + const mergedPropsAndData = assign({}, this.props, this.data); if ( !haveOwnPropsChanged && @@ -265,7 +275,6 @@ export default function connect(opts: ConnectOptions) { } this.renderedElement = createElement(WrappedComponent, mergedPropsAndData); - return this.renderedElement; } diff --git a/test/Provider.tsx b/test/Provider.tsx index 6837389b22..6b539cd153 100644 --- a/test/Provider.tsx +++ b/test/Provider.tsx @@ -14,6 +14,8 @@ const { expect } = chai; import Provider from '../src/Provider'; +import ApolloClient from 'apollo-client'; + describe(' Component', () => { class Child extends React.Component { @@ -22,6 +24,8 @@ describe(' Component', () => { } }; + const client = new ApolloClient(); + // XXX why isn't this working with TestUtils typing? // Child.contextType = { // store: React.PropTypes.object.isRequired, @@ -32,7 +36,7 @@ describe(' Component', () => { const store = createStore(() => ({})); const wrapper = shallow( - +
); @@ -40,18 +44,18 @@ describe(' Component', () => { expect(wrapper.contains(
)).to.equal(true); }); - it('should throw if rendered without a child component', () => { - const store = createStore(() => ({})); + // it('should throw if rendered without a child component', () => { + // const store = createStore(() => ({})); - try { - shallow( - - ); - } catch (e) { - expect(e).to.be.instanceof(Error); - } + // try { + // shallow( + // + // ); + // } catch (e) { + // expect(e).to.be.instanceof(Error); + // } - }); + // }); // it('should add the store to the child context', () => { // const store = createStore(() => ({})); diff --git a/test/connect.tsx b/test/connect.tsx new file mode 100644 index 0000000000..50c64b8f29 --- /dev/null +++ b/test/connect.tsx @@ -0,0 +1,328 @@ +/// + +import * as React from 'react'; +import * as chai from 'chai'; +import { mount } from 'enzyme'; +import { createStore } from 'redux'; +import { connect as ReactReduxConnect } from 'react-redux'; + +import { + GraphQLResult, + parse, + print, +} from 'graphql'; + +import ApolloClient from 'apollo-client'; + +declare function require(name: string); +import chaiEnzyme = require('chai-enzyme'); + +chai.use(chaiEnzyme()); // Note the invocation at the end +const { expect } = chai; + +import connect from '../src/connect'; + +describe('connect', () => { + + class Passthrough extends React.Component { + render() { + return ; + } + }; + + class ProviderMock extends React.Component { + + static childContextTypes = { + store: React.PropTypes.object.isRequired, + client: React.PropTypes.object.isRequired, + }; + + getChildContext() { + return { + store: this.props.store, + client: this.props.client, + }; + } + + render() { + return React.Children.only(this.props.children); + } + }; + + describe('redux passthrough', () => { + it('should allow mapStateToProps', () => { + const store = createStore(() => ({ + foo: 'bar', + baz: 42, + hello: 'world', + })); + + const mapStateToProps = ({ foo, baz }) => ({ foo, baz }); + + @ReactReduxConnect(mapStateToProps) + class Container extends React.Component { + render() { + return ; + } + }; + + @connect({mapStateToProps}) + class ApolloContainer extends React.Component { + render() { + return ; + } + }; + + const wrapper = mount( + + + + ); + + const apolloWrapper = mount( + + + + ); + + const reduxProps = wrapper.find('span').props(); + const apolloProps = apolloWrapper.find('span').props(); + + expect(reduxProps).to.deep.equal(apolloProps); + + }); + + it('should allow mapDispatchToProps', () => { + function doSomething(thing) { + return { + type: 'APPEND', + body: thing, + }; + }; + + const store = createStore(() => ({ + foo: 'bar', + baz: 42, + hello: 'world', + })); + + const mapDispatchToProps = dispatch => ({ + doSomething: (whatever) => dispatch(doSomething(whatever)), + }); + + @ReactReduxConnect(null, mapDispatchToProps) + class Container extends React.Component { + render() { + return ; + } + }; + + @connect({mapDispatchToProps}) + class ApolloContainer extends React.Component { + render() { + return ; + } + }; + + const wrapper = mount( + + + + ); + + const apolloWrapper = mount( + + + + ); + + const reduxProps = wrapper.find('span').props() as any; + const apolloProps = apolloWrapper.find('span').props() as any; + + expect(reduxProps.doSomething()).to.deep.equal(apolloProps.doSomething()); + + }); + + it('should allow mergeProps', () => { + function doSomething(thing) { + return { + type: 'APPEND', + body: thing, + }; + }; + + const store = createStore(() => ({ + foo: 'bar', + baz: 42, + hello: 'world', + })); + + const mapStateToProps = ({ foo, baz }) => ({ foo, baz }); + + const mapDispatchToProps = dispatch => ({ + doSomething: (whatever) => dispatch(doSomething(whatever)), + }); + + const mergeProps = (stateProps, dispatchProps, ownProps) => { + return { + bar: stateProps.baz + 1, + makeSomething: dispatchProps.doSomething, + hallPass: ownProps.pass, + }; + }; + + @ReactReduxConnect(mapStateToProps, mapDispatchToProps, mergeProps) + class Container extends React.Component { + render() { + return ; + } + }; + + @connect({mapStateToProps, mapDispatchToProps, mergeProps}) + class ApolloContainer extends React.Component { + render() { + return ; + } + }; + + const wrapper = mount( + + + + ); + + const apolloWrapper = mount( + + + + ); + + const reduxProps = wrapper.find('span').props() as any; + const apolloProps = apolloWrapper.find('span').props() as any; + + expect(reduxProps.makeSomething()).to.deep.equal(apolloProps.makeSomething()); + expect(reduxProps.bar).to.equal(apolloProps.bar); + expect(reduxProps.hallPass).to.equal(apolloProps.hallPass); + + }); + }); + + describe('apollo methods', () => { + it('binds a query to props', () => { + const store = createStore(() => ({ + foo: 'bar', + baz: 42, + hello: 'world', + })); + + const query = ` + query people { + allPeople(first: 1) { + people { + name + } + } + } + `; + + const data = { + allPeople: { + people: [ + { + name: 'Luke Skywalker', + }, + ], + }, + }; + + const networkInterface = mockNetworkInterface({ + request: { query }, + result: { data }, + }); + + const client = new ApolloClient({ + networkInterface, + }); + + function mapQueriesToProps({ watchQuery }) { + return { + category: watchQuery({ + query: ` + query people { + allPeople(first: 1) { + people { + name + } + } + } + `, + }), + }; + }; + + @connect({ mapQueriesToProps }) + class Container extends React.Component { + render() { + return ; + } + }; + + const wrapper = mount( + + + + ); + + const props = wrapper.find('span').props() as any; + + expect(props.category).to.exist; + expect(props.category.loading).to.be.true; + }); + }); +}); + +function mockNetworkInterface( + mockedRequest: { + request: any, + result: GraphQLResult, + } +) { + const requestToResultMap: any = {}; + const { request, result } = mockedRequest; + + // Populate set of mocked requests + requestToResultMap[requestToKey(request)] = result as GraphQLResult; + + // A mock for the query method + const queryMock = (req: Request) => { + return new Promise((resolve, reject) => { + // network latency + setTimeout(() => { + const resultData = requestToResultMap[requestToKey(req)]; + if (!resultData) { + throw new Error(`Passed request that wasn't mocked: ${requestToKey(req)}`); + } + resolve(resultData); + }, 100); + + }); + }; + + return { + query: queryMock, + _uri: 'mock', + _opts: {}, + _middlewares: [], + use() { return; }, + }; +} + + +function requestToKey(request: any): string { + const query = request.query && print(parse(request.query)); + + return JSON.stringify({ + variables: request.variables, + query, + }); +} diff --git a/tsconfig.json b/tsconfig.json index 6464c685d8..763cf3228f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,7 @@ "rootDir": ".", "outDir": "lib", "allowSyntheticDefaultImports": true, + "experimentalDecorators": true, "pretty": true, "removeComments": true, "jsx": "react" diff --git a/typings.json b/typings.json index e27e7d0c85..408601c8cc 100644 --- a/typings.json +++ b/typings.json @@ -4,7 +4,11 @@ "mocha": "registry:dt/mocha#2.2.5+20160317120654", "react": "registry:dt/react#0.14.0+20160319053454", "react-addons-test-utils": "registry:dt/react-addons-test-utils#0.14.0+20160316155526", - "react-dom": "registry:dt/react-dom#0.14.0+20160316155526" + "react-dom": "registry:dt/react-dom#0.14.0+20160316155526", + "graphql": "github:nitintutlani/typed-graphql#38bdf66576693dfe808a34876295e989f9199115", + "isomorphic-fetch": "registry:dt/isomorphic-fetch#0.0.0+20160316155526", + "es6-promise": "registry:dt/es6-promise#0.0.0+20160317120654", + "node": "registry:dt/node#4.0.0+20160319033040" }, "devDependencies": { "chai": "registry:npm/chai#3.5.0+20160328084239", @@ -12,6 +16,7 @@ "sinon": "registry:npm/sinon#1.16.0+20160309002336" }, "dependencies": { + "apollo-client": "file:node_modules/apollo-client/lib/src/index.d.ts", "invariant": "registry:npm/invariant#2.0.0+20160211003958", "lodash": "registry:npm/lodash#4.0.0+20160412191219", "object-assign": "registry:npm/object-assign#4.0.1+20160301180549", From 3ab66c9df29fbe6fc9a59e455f427b1355690747 Mon Sep 17 00:00:00 2001 From: James Baxley Date: Fri, 15 Apr 2016 09:42:17 -0400 Subject: [PATCH 04/16] more tests added --- design.md | 57 ------------ src/connect.tsx | 2 +- test/connect.tsx | 226 ++++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 214 insertions(+), 71 deletions(-) delete mode 100644 design.md diff --git a/design.md b/design.md deleted file mode 100644 index 425b3a7756..0000000000 --- a/design.md +++ /dev/null @@ -1,57 +0,0 @@ -# Design principles of the Apollo Client - -If we are building a client-side GraphQL client and cache, we should have some goals that carve out our part of that space. These are the competitive advantages we believe this library will have over others that implement a similar set of functionality. - -## Principles - -The Apollo Client should be... - -1. Functional - this library should bring benefits to an application's developers and end users to achieve performance, usability, and simplicity. It should have more features than [Lokka](https://github.com/kadirahq/lokka) but less than [Relay](https://github.com/facebook/relay). -1. Transparent - a developer should be able to keep everything the Apollo Client is doing in their mind at once. They don't necessarily need to understand every part of the implementation, but nothing it's doing should be a surprise. This principle should take precedence over fine-grained performance optimizations. -1. Standalone - the published library should not impose any specific build or runtime environment, view framework, router, or development philosophies other than JavaScript, NPM, or GraphQL. When you install it via NPM in any NPM-compatible development environment, the batteries are included. Anything that isn't included, like certain polyfills, is clearly documented. -1. Compatible - the Apollo Client core should be compatible with as many GraphQL schemas, transports, and execution models as possible. There might be optimizations that rely on specific server-side features, but it should "just work" even in the absence of those features. -1. Usable - given the above, developer experience should be a priority. The API for the application developer should have a simple mental model, a minimal surface area, and be clearly documented. - -## Implementation - -I think the principles above naturally lead to the following constraints on the implementation: - -### Necessary features - -I think there is a "minimum viable" set of features for a good GraphQL client. Almost all GraphQL clients that aren't Relay don't have some of these features, and the necessity to have them, even when they don't have many other requirements, is what drives people to use Relay which often brings more functionality and complexity than they need for their application. Bringing us to [this graph from React Conf](https://www.dropbox.com/s/kppd4kdz40h96kj/Screenshot%202016-03-19%2016.40.19.png?dl=0) ([full slides here](https://github.com/jaredly/reactconf)). - -Based on talking to some developers, I believe that list includes, in no particular order: - -- Optimistic UI for mutations -- A cache so that you don't refetch data you already have -- The ability to manually refetch data when you know it has changed -- The ability to preload data you might need later -- Minimal server roundtrips to render the initial UI -- Query aggregation from your UI tree -- Basic handling of pagination, most critically being able to fetch a new page of items when you already have some - -The implementation process will determine the order in which these are completed. - -### Stateless, well-documented store format - -All state of the GraphQL cache should be kept in a single immutable state object (referred to as the "store"), and every operation on the store should be implemented as a function from the previous store object to a new one. The store format should be easily understood and inspected by the application developer, rather than an implementation detail of the library. - -This will have many benefits compared to other approaches: - -1. Simple debugging/testing both of the Apollo client itself and apps built with it, by making it possible to analyze the store contents directly and step through the different states -2. Trivial optimistic UI using time-traveling and reordering of actions taken on the store -3. Easy integration of extensions/middlewares by sharing a common data interchange format - -To enable this, we need to have clear documentation about the format of this store object, so that people can write extensions around it and be sure that they will remain compatible. - -### Lowest-common-denominator APIs between modules - -APIs between the different parts of the library should be in simple, standard, easy-to-understand formats. We should avoid creating Apollo-specific representations of queries and data, and stick to the tools available - GraphQL strings, the standard GraphQL AST, selection sets, etc. - -If we do invent new data interchange APIs, they need to be clearly documented, have a good and documented reason for existing, and be stable so that plugins and extensions can use them. - -### Simple modules, each with a clear purpose - -There are many utilities that any smart GraphQL cache will need around query diffing, reading a cache, etc. These should be written in a way that it would make sense to use them in any GraphQL client. In short, this is a set of libraries, not a framework. - -Each module should have minimal dependencies on the runtime environment. For example, the network layer can assume HTTP, but not any other part. diff --git a/src/connect.tsx b/src/connect.tsx index 7c6e0bd801..700183e1e5 100644 --- a/src/connect.tsx +++ b/src/connect.tsx @@ -161,7 +161,7 @@ export default function connect(opts?: ConnectOptions) { ); const storeState = this.store.getState(); - this.state = { storeState }; + this.state = assign({}, storeState); this.data = {}; } diff --git a/test/connect.tsx b/test/connect.tsx index 50c64b8f29..63a7dcdd2d 100644 --- a/test/connect.tsx +++ b/test/connect.tsx @@ -246,17 +246,73 @@ describe('connect', () => { function mapQueriesToProps({ watchQuery }) { return { - category: watchQuery({ - query: ` - query people { - allPeople(first: 1) { - people { - name - } - } - } - `, - }), + people: watchQuery({ query }), + }; + }; + + @connect({ mapQueriesToProps }) + class Container extends React.Component { + render() { + return ; + } + }; + + const wrapper = mount( + + + + ); + + const props = wrapper.find('span').props() as any; + + expect(props.people).to.exist; + expect(props.people.loading).to.be.true; + }); + + it('allows variables as part of the request', () => { + const store = createStore(() => ({ + foo: 'bar', + baz: 42, + hello: 'world', + })); + + const query = ` + query people($count: Int) { + allPeople(first: $count) { + people { + name + } + } + } + `; + + const variables = { + count: 1, + }; + + const data = { + allPeople: { + people: [ + { + name: 'Luke Skywalker', + }, + ], + }, + }; + + + const networkInterface = mockNetworkInterface({ + request: { query, variables }, + result: { data }, + }); + + const client = new ApolloClient({ + networkInterface, + }); + + function mapQueriesToProps({ watchQuery }) { + return { + people: watchQuery({ query, variables }), }; }; @@ -275,8 +331,152 @@ describe('connect', () => { const props = wrapper.find('span').props() as any; - expect(props.category).to.exist; - expect(props.category.loading).to.be.true; + expect(props.people).to.exist; + expect(props.people.loading).to.be.true; + }); + + it('can use passed props as part of the query', () => { + const store = createStore(() => ({ + foo: 'bar', + baz: 42, + hello: 'world', + })); + + const query = ` + query people($count: Int) { + allPeople(first: $count) { + people { + name + } + } + } + `; + + const variables = { + count: 1, + }; + + const data = { + allPeople: { + people: [ + { + name: 'Luke Skywalker', + }, + ], + }, + }; + + + const networkInterface = mockNetworkInterface({ + request: { query, variables }, + result: { data }, + }); + + const client = new ApolloClient({ + networkInterface, + }); + + function mapQueriesToProps({ watchQuery, ownProps }) { + expect(ownProps.passedCountProp).to.equal(2); + return { + people: watchQuery({ + query, + variables: { + count: ownProps.passedCountProp, + }, + }), + }; + }; + + @connect({ mapQueriesToProps }) + class Container extends React.Component { + render() { + return ; + } + }; + + const wrapper = mount( + + + + ); + + const props = wrapper.find('span').props() as any; + + expect(props.people).to.exist; + expect(props.people.loading).to.be.true; + }); + + it('can use the redux state as part of the query', () => { + const store = createStore(() => ({ + foo: 'bar', + baz: 42, + hello: 'world', + })); + + const query = ` + query people($count: Int) { + allPeople(first: $count) { + people { + name + } + } + } + `; + + const variables = { + count: 1, + }; + + const data = { + allPeople: { + people: [ + { + name: 'Luke Skywalker', + }, + ], + }, + }; + + + const networkInterface = mockNetworkInterface({ + request: { query, variables }, + result: { data }, + }); + + const client = new ApolloClient({ + networkInterface, + }); + + function mapQueriesToProps({ watchQuery, state }) { + expect(state.hello).to.equal('world'); + return { + people: watchQuery({ + query, + variables: { + count: 1, + }, + }), + }; + }; + + @connect({ mapQueriesToProps }) + class Container extends React.Component { + render() { + return ; + } + }; + + const wrapper = mount( + + + + ); + + const props = wrapper.find('span').props() as any; + + expect(props.people).to.exist; + expect(props.people.loading).to.be.true; }); }); }); From 159bdae8734abf1da259a3d2007ff8735959c1d9 Mon Sep 17 00:00:00 2001 From: James Baxley Date: Fri, 15 Apr 2016 11:08:11 -0400 Subject: [PATCH 05/16] query lifecycle test --- test/connect.tsx | 159 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 152 insertions(+), 7 deletions(-) diff --git a/test/connect.tsx b/test/connect.tsx index 63a7dcdd2d..6ff2afaebe 100644 --- a/test/connect.tsx +++ b/test/connect.tsx @@ -5,6 +5,7 @@ import * as chai from 'chai'; import { mount } from 'enzyme'; import { createStore } from 'redux'; import { connect as ReactReduxConnect } from 'react-redux'; +// import { spy } from 'sinon'; import { GraphQLResult, @@ -478,6 +479,152 @@ describe('connect', () => { expect(props.people).to.exist; expect(props.people.loading).to.be.true; }); + + it('allows for multiple queries', () => { + const store = createStore(() => ({ + foo: 'bar', + baz: 42, + hello: 'world', + })); + + const peopleQuery = ` + query people($count: Int) { + allPeople(first: $count) { + people { + name + } + } + } + `; + + const peopleData = { + allPeople: { + people: [ + { + name: 'Luke Skywalker', + }, + ], + }, + }; + + // const shipData = { + // allStarships: { + // starships: [ + // { + // name: 'CR90 corvette', + // }, + // ], + // }, + // }; + + const shipQuery = ` + query starships($count: Int) { + allStarships(first: $count) { + starships { + name + } + } + } + `; + + const variables = { count: 1 }; + + const networkInterface = mockNetworkInterface({ + request: { query: peopleQuery, variables }, + result: { data: peopleData }, + }); + + const client = new ApolloClient({ + networkInterface, + }); + + function mapQueriesToProps({ watchQuery }) { + return { + people: watchQuery({ query: peopleQuery, variables }), + ships: watchQuery({ query: shipQuery, variables }), + }; + }; + + @connect({ mapQueriesToProps }) + class Container extends React.Component { + render() { + return ; + } + }; + + const wrapper = mount( + + + + ); + + const props = wrapper.find('span').props() as any; + + expect(props.people).to.exist; + expect(props.people.loading).to.be.true; + + expect(props.ships).to.exist; + expect(props.ships.loading).to.be.true; + }); + + + it('should update the props of the child component when data is returned', (done) => { + const store = createStore(() => ({ })); + + const query = ` + query people { + luke: allPeople(first: 1) { + people { + name + } + } + } + `; + + const data = { + luke: { + people: [ + { + name: 'Luke Skywalker', + }, + ], + }, + }; + + const networkInterface = mockNetworkInterface({ + request: { query }, + result: { data }, + }); + + const client = new ApolloClient({ + networkInterface, + }); + + function mapQueriesToProps({ watchQuery }) { + return { + luke: watchQuery({ + query, + }), + }; + }; + + @connect({ mapQueriesToProps }) + class Container extends React.Component { + componentWillReceiveProps(nextProps) { + expect(nextProps.luke.result).to.deep.equal(data); + done(); + } + render() { + return ; + } + }; + + mount( + + + + ); + }); }); }); @@ -497,13 +644,11 @@ function mockNetworkInterface( const queryMock = (req: Request) => { return new Promise((resolve, reject) => { // network latency - setTimeout(() => { - const resultData = requestToResultMap[requestToKey(req)]; - if (!resultData) { - throw new Error(`Passed request that wasn't mocked: ${requestToKey(req)}`); - } - resolve(resultData); - }, 100); + const resultData = requestToResultMap[requestToKey(req)]; + if (!resultData) { + throw new Error(`Passed request that wasn't mocked: ${requestToKey(req)}`); + } + resolve(resultData); }); }; From 31daa98d82e745f4681dbfd3377c6da3152b145e Mon Sep 17 00:00:00 2001 From: James Baxley Date: Fri, 15 Apr 2016 11:09:29 -0400 Subject: [PATCH 06/16] better lifecycle hooks --- test/connect.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/connect.tsx b/test/connect.tsx index 6ff2afaebe..60bd48e204 100644 --- a/test/connect.tsx +++ b/test/connect.tsx @@ -610,8 +610,9 @@ describe('connect', () => { @connect({ mapQueriesToProps }) class Container extends React.Component { - componentWillReceiveProps(nextProps) { - expect(nextProps.luke.result).to.deep.equal(data); + componentDidUpdate(prevProps) { + expect(prevProps.luke.loading).to.be.true; + expect(this.props.luke.result).to.deep.equal(data); done(); } render() { From 791ac570fdb22cb99e7b8c48ccffe529dc469489 Mon Sep 17 00:00:00 2001 From: James Baxley Date: Fri, 15 Apr 2016 23:21:39 -0400 Subject: [PATCH 07/16] use adjusted query api --- src/connect.tsx | 32 +++++++++++++++++++++++++++----- test/connect.tsx | 32 +++++++++++++++----------------- 2 files changed, 42 insertions(+), 22 deletions(-) diff --git a/src/connect.tsx b/src/connect.tsx index 700183e1e5..85d0f19c25 100644 --- a/src/connect.tsx +++ b/src/connect.tsx @@ -32,14 +32,11 @@ import { export declare interface MapQueriesToPropsOptions { ownProps: any; state: any; - watchQuery(opts: any): any; // WatchQueryHandle }; export declare interface MapMutationsToPropsOptions { ownProps: any; state: any; - mutate(opts: any): any; // MutationHandle - onPostReply(raw: any): any; // MutationHandle }; export declare interface ConnectOptions { @@ -141,10 +138,12 @@ export default function connect(opts?: ConnectOptions) { public state: any; // redux state public props: any; // passed props public data: any; // apollo data + public queryHandles: any; public haveOwnPropsChanged: boolean; public hasQueryDataChanged: boolean; public hasMutationDataChanged: boolean; + public renderedElement: any; constructor(props, context) { @@ -200,7 +199,6 @@ export default function connect(opts?: ConnectOptions) { const { watchQuery } = this.client; const queryHandles = mapQueriesToProps({ - watchQuery, state, ownProps: props, }); @@ -213,8 +211,9 @@ export default function connect(opts?: ConnectOptions) { continue; } - const handle = queryHandles[key]; + const handle = watchQuery(queryHandles[key]); + // XXX preload data from store // bind key to state for updating this.data[key] = defaultQueryData; @@ -237,6 +236,29 @@ export default function connect(opts?: ConnectOptions) { handleQueryData(handle: any, key: string) { // bind each handle to updating and rerendering when data // has been recieved + + // XXX use newer subscribe method + // XXX merge this.data instead of a full replace + /* + + handle.subscribe({ + onResult(({ error, data }) => { + this.data[key] = { + loading: false, + result: data, + error, + } + }), + onError((error) => { + this.data[key] = { + loading: false, + result: null, // + error, + } + }), + }) + + */ handle.onResult(({ error, data }) => { this.data[key] = { loading: false, diff --git a/test/connect.tsx b/test/connect.tsx index 60bd48e204..a1a2e722bd 100644 --- a/test/connect.tsx +++ b/test/connect.tsx @@ -245,9 +245,9 @@ describe('connect', () => { networkInterface, }); - function mapQueriesToProps({ watchQuery }) { + function mapQueriesToProps() { return { - people: watchQuery({ query }), + people: { query }, }; }; @@ -311,9 +311,9 @@ describe('connect', () => { networkInterface, }); - function mapQueriesToProps({ watchQuery }) { + function mapQueriesToProps() { return { - people: watchQuery({ query, variables }), + people: { query, variables }, }; }; @@ -377,15 +377,15 @@ describe('connect', () => { networkInterface, }); - function mapQueriesToProps({ watchQuery, ownProps }) { + function mapQueriesToProps({ ownProps }) { expect(ownProps.passedCountProp).to.equal(2); return { - people: watchQuery({ + people: { query, variables: { count: ownProps.passedCountProp, }, - }), + }, }; }; @@ -449,15 +449,15 @@ describe('connect', () => { networkInterface, }); - function mapQueriesToProps({ watchQuery, state }) { + function mapQueriesToProps({ state }) { expect(state.hello).to.equal('world'); return { - people: watchQuery({ + people: { query, variables: { count: 1, }, - }), + }, }; }; @@ -538,10 +538,10 @@ describe('connect', () => { networkInterface, }); - function mapQueriesToProps({ watchQuery }) { + function mapQueriesToProps() { return { - people: watchQuery({ query: peopleQuery, variables }), - ships: watchQuery({ query: shipQuery, variables }), + people: { query: peopleQuery, variables }, + ships: { query: shipQuery, variables }, }; }; @@ -600,11 +600,9 @@ describe('connect', () => { networkInterface, }); - function mapQueriesToProps({ watchQuery }) { + function mapQueriesToProps() { return { - luke: watchQuery({ - query, - }), + luke: { query }, }; }; From cdc8621b21f3388551f3e30c55cd44632933ead0 Mon Sep 17 00:00:00 2001 From: James Baxley Date: Sat, 16 Apr 2016 01:12:03 -0400 Subject: [PATCH 08/16] adjust based on api discussions and add support for mutations --- src/Provider.tsx | 13 +- src/connect.tsx | 131 +++-- test/Provider.tsx | 10 + test/connect.tsx | 1226 +++++++++++++++++++++++++++++++++------------ 4 files changed, 1024 insertions(+), 356 deletions(-) diff --git a/src/Provider.tsx b/src/Provider.tsx index f814b35742..6bea8b2382 100644 --- a/src/Provider.tsx +++ b/src/Provider.tsx @@ -13,7 +13,7 @@ import { import ApolloClient from 'apollo-client'; export declare interface ProviderProps { - store: Store; + store?: Store; client: ApolloClient; } @@ -39,7 +39,16 @@ export default class Provider extends Component { constructor(props, context) { super(props, context); this.client = props.client; - this.store = props.store; + + if (props.store) { + this.store = props.store; + return; + } + + // intialize the built in store if none is passed in + props.client.initStore(); + this.store = props.client.store; + } getChildContext() { diff --git a/src/connect.tsx b/src/connect.tsx index 85d0f19c25..c5dbc9b830 100644 --- a/src/connect.tsx +++ b/src/connect.tsx @@ -29,6 +29,12 @@ import { Store, } from 'redux'; +import ApolloClient from 'apollo-client'; + +import { + GraphQLResult, +} from 'graphql'; + export declare interface MapQueriesToPropsOptions { ownProps: any; state: any; @@ -55,6 +61,7 @@ const defaultQueryData = { error: null, result: null, }; +const defaultMutationData = assign({}, defaultQueryData); function getDisplayName(WrappedComponent) { return WrappedComponent.displayName || WrappedComponent.name || 'Component'; @@ -75,25 +82,6 @@ export default function connect(opts?: ConnectOptions) { delete opts.mapQueriesToProps; delete opts.mapMutationsToProps; - /* - - This connect is a wrapper around react-redux's connect. If called - without any apollo specific actions, we can pass the props straight to - react-redux's connect and call it quits - - */ - if (!mapQueriesToProps && !mapMutationsToProps) { - const { mapStateToProps, mapDispatchToProps, mergeProps, options } = opts; - return function passReactReduxArgumentsPlain(WrappedComponent) { - return ReactReduxConnect( - mapStateToProps, - mapDispatchToProps, - mergeProps, - options - )(WrappedComponent); - }; - }; - /* mapQueriesToProps: @@ -132,19 +120,27 @@ export default function connect(opts?: ConnectOptions) { client: PropTypes.object.isRequired, }; - public version: number; - public store: Store; - public client: any; // apollo client + // react and react dev tools (HMR) needs public state: any; // redux state public props: any; // passed props - public data: any; // apollo data + public version: number; + + // data storage + private store: Store; + private client: ApolloClient; // apollo client + private data: any; // apollo data + + // request / action storage + private queryHandles: any; + private mutations: any; - public queryHandles: any; - public haveOwnPropsChanged: boolean; - public hasQueryDataChanged: boolean; - public hasMutationDataChanged: boolean; + // calculated switches to control rerenders + private haveOwnPropsChanged: boolean; + private hasQueryDataChanged: boolean; + private hasMutationDataChanged: boolean; - public renderedElement: any; + // the element to render + private renderedElement: any; constructor(props, context) { super(props, context); @@ -152,7 +148,7 @@ export default function connect(opts?: ConnectOptions) { this.store = props.store || context.store; this.client = props.client || context.client; - invariant(this.client, + invariant(!!this.client, `Could not find "client" in either the context or ` + `props of "${apolloConnectDisplayName}". ` + `Either wrap the root component in a , ` + @@ -163,11 +159,13 @@ export default function connect(opts?: ConnectOptions) { this.state = assign({}, storeState); this.data = {}; + this.mutations = {}; } componentWillMount() { const { props, state } = this; this.subscribeToAllQueries(props, state); + this.createAllMutationHandles(props, state); } // // best practice says make external requests in `componentDidMount` as to @@ -273,19 +271,92 @@ export default function connect(opts?: ConnectOptions) { }); } + createAllMutationHandles(props: any, state: any): void { + const mutations = mapMutationsToProps({ + state, + ownProps: props, + }); + + if (isObject && Object.keys(mutations).length) { + for (const key in mutations) { + if (!mutations.hasOwnProperty(key)) { + continue; + } + + // setup thunk of mutation + const handle = this.createMutationHandle(key, mutations[key]); + + // XXX should we validate we have what we need to prevent errors? + + // bind key to state for updating + this.data[key] = defaultMutationData; + this.mutations[key] = handle; + } + } + } + + createMutationHandle(key: string, method: () => { mutation: string, variables?: any }): () => Promise { + const { mutate } = this.client; + const { store } = this; + + // middleware to update the props to send data to wrapped component + // when the mutation is done + const forceRender = ({ errors, data }: GraphQLResult): GraphQLResult => { + this.data[key] = { + loading: false, + result: data, + error: errors, + }; + + this.hasMutationDataChanged = true; + + // update state to latest of redux store + // this forces a render of children + this.setState(store.getState()); + + return { + errors, + data, + }; + }; + + return (...args) => { + // XXX should we pass the client as `this` to the mutation method? + // it could be useful for looking up specific apollo info to shape a mutation? + const { mutation, variables } = method.apply(this.client, args); + return mutate({ mutation, variables }) + .then(forceRender) + .catch(error => forceRender({ errors: error })); + }; + } + + render() { const { haveOwnPropsChanged, hasQueryDataChanged, hasMutationDataChanged, renderedElement, + mutations, + props, + data, } = this; this.haveOwnPropsChanged = false; this.hasQueryDataChanged = false; this.hasMutationDataChanged = false; - const mergedPropsAndData = assign({}, this.props, this.data); + let clientProps = { + mutate: this.client.mutate, + query: this.client.query, + } as any; + + // XXX add in props.mutations if they are needed + if (Object.keys(mutations).length) { + clientProps.mutations = mutations; + } + + const mergedPropsAndData = assign({}, props, data, clientProps); if ( !haveOwnPropsChanged && diff --git a/test/Provider.tsx b/test/Provider.tsx index 6b539cd153..90d46b96bb 100644 --- a/test/Provider.tsx +++ b/test/Provider.tsx @@ -44,6 +44,16 @@ describe(' Component', () => { expect(wrapper.contains(
)).to.equal(true); }); + it('should not require a store', () => { + const wrapper = shallow( + +
+ + ); + + expect(wrapper.contains(
)).to.equal(true); + }); + // it('should throw if rendered without a child component', () => { // const store = createStore(() => ({})); diff --git a/test/connect.tsx b/test/connect.tsx index a1a2e722bd..dd3215e5d5 100644 --- a/test/connect.tsx +++ b/test/connect.tsx @@ -5,6 +5,7 @@ import * as chai from 'chai'; import { mount } from 'enzyme'; import { createStore } from 'redux'; import { connect as ReactReduxConnect } from 'react-redux'; +import assign = require('object-assign'); // import { spy } from 'sinon'; import { @@ -50,6 +51,123 @@ describe('connect', () => { } }; + describe('prop api', () => { + it('should pass `ApolloClient.query` as props.query', () => { + const store = createStore(() => ({ })); + const query = ` + query people { + allPeople(first: 1) { + people { + name + } + } + } + `; + + const data = { + allPeople: { + people: [ + { + name: 'Luke Skywalker', + }, + ], + }, + }; + + const networkInterface = mockNetworkInterface({ + request: { query }, + result: { data }, + }); + + const client = new ApolloClient({ + networkInterface, + }); + + @connect() + class Container extends React.Component { + render() { + return ; + } + }; + + const wrapper = mount( + + + + ); + + + const props = wrapper.find('span').props() as any; + + expect(props.query).to.exist; + expect(props.query({ query })).to.be.instanceof(Promise); + + }); + + it('should pass `ApolloClient.mutate` as props.mutate', () => { + const store = createStore(() => ({ })); + const client = new ApolloClient(); + + @connect() + class Container extends React.Component { + render() { + return ; + } + }; + + const wrapper = mount( + + + + ); + + + const props = wrapper.find('span').props() as any; + + expect(props.mutate).to.exist; + try { + expect(props.mutate()).to.be.instanceof(Promise); + } catch (e) { + expect(e).to.be.instanceof(TypeError); + }; + + }); + + it('should pass mutation methods as props.mutations dictionary', () => { + const store = createStore(() => ({ })); + + function mapMutationsToProps() { + return { + test: () => ({ + mutate: ``, + }), + }; + }; + + @connect({ mapMutationsToProps }) + class Container extends React.Component { + render() { + return ; + } + }; + + const wrapper = mount( + + + + ); + + + const props = wrapper.find('span').props() as any; + + expect(props.mutations).to.exist; + expect(props.mutations.test).to.exist; + expect(props.mutations.test).to.be.instanceof(Function); + + }); + + }); + describe('redux passthrough', () => { it('should allow mapStateToProps', () => { const store = createStore(() => ({ @@ -86,7 +204,10 @@ describe('connect', () => { ); - const reduxProps = wrapper.find('span').props(); + const reduxProps = assign({}, wrapper.find('span').props(), { + query: undefined, + mutate: undefined, + }); const apolloProps = apolloWrapper.find('span').props(); expect(reduxProps).to.deep.equal(apolloProps); @@ -137,7 +258,10 @@ describe('connect', () => { ); - const reduxProps = wrapper.find('span').props() as any; + const reduxProps = assign({}, wrapper.find('span').props(), { + query: () => {/* tslint */}, + mutate: () => {/* tslint */}, + }) as any; const apolloProps = apolloWrapper.find('span').props() as any; expect(reduxProps.doSomething()).to.deep.equal(apolloProps.doSomething()); @@ -198,7 +322,10 @@ describe('connect', () => { ); - const reduxProps = wrapper.find('span').props() as any; + const reduxProps = assign({}, wrapper.find('span').props(), { + query: () => {/* tslint */}, + mutate: () => {/* tslint */}, + }) as any; const apolloProps = apolloWrapper.find('span').props() as any; expect(reduxProps.makeSomething()).to.deep.equal(apolloProps.makeSomething()); @@ -209,420 +336,871 @@ describe('connect', () => { }); describe('apollo methods', () => { - it('binds a query to props', () => { - const store = createStore(() => ({ - foo: 'bar', - baz: 42, - hello: 'world', - })); - - const query = ` - query people { - allPeople(first: 1) { - people { - name + describe('queries', () => { + it('binds a query to props', () => { + const store = createStore(() => ({ + foo: 'bar', + baz: 42, + hello: 'world', + })); + + const query = ` + query people { + allPeople(first: 1) { + people { + name + } } } - } - `; + `; + + const data = { + allPeople: { + people: [ + { + name: 'Luke Skywalker', + }, + ], + }, + }; - const data = { - allPeople: { - people: [ - { - name: 'Luke Skywalker', - }, - ], - }, - }; + const networkInterface = mockNetworkInterface({ + request: { query }, + result: { data }, + }); - const networkInterface = mockNetworkInterface({ - request: { query }, - result: { data }, - }); + const client = new ApolloClient({ + networkInterface, + }); - const client = new ApolloClient({ - networkInterface, + function mapQueriesToProps() { + return { + people: { query }, + }; + }; + + @connect({ mapQueriesToProps }) + class Container extends React.Component { + render() { + return ; + } + }; + + const wrapper = mount( + + + + ); + + const props = wrapper.find('span').props() as any; + + expect(props.people).to.exist; + expect(props.people.loading).to.be.true; }); - function mapQueriesToProps() { - return { - people: { query }, + it('allows variables as part of the request', () => { + const store = createStore(() => ({ + foo: 'bar', + baz: 42, + hello: 'world', + })); + + const query = ` + query people($count: Int) { + allPeople(first: $count) { + people { + name + } + } + } + `; + + const variables = { + count: 1, }; - }; - @connect({ mapQueriesToProps }) - class Container extends React.Component { - render() { - return ; - } - }; + const data = { + allPeople: { + people: [ + { + name: 'Luke Skywalker', + }, + ], + }, + }; - const wrapper = mount( - - - - ); - const props = wrapper.find('span').props() as any; + const networkInterface = mockNetworkInterface({ + request: { query, variables }, + result: { data }, + }); - expect(props.people).to.exist; - expect(props.people.loading).to.be.true; - }); + const client = new ApolloClient({ + networkInterface, + }); - it('allows variables as part of the request', () => { - const store = createStore(() => ({ - foo: 'bar', - baz: 42, - hello: 'world', - })); + function mapQueriesToProps() { + return { + people: { query, variables }, + }; + }; - const query = ` - query people($count: Int) { - allPeople(first: $count) { - people { - name - } + @connect({ mapQueriesToProps }) + class Container extends React.Component { + render() { + return ; } - } - `; + }; - const variables = { - count: 1, - }; - - const data = { - allPeople: { - people: [ - { - name: 'Luke Skywalker', - }, - ], - }, - }; + const wrapper = mount( + + + + ); + const props = wrapper.find('span').props() as any; - const networkInterface = mockNetworkInterface({ - request: { query, variables }, - result: { data }, + expect(props.people).to.exist; + expect(props.people.loading).to.be.true; }); - const client = new ApolloClient({ - networkInterface, - }); + it('can use passed props as part of the query', () => { + const store = createStore(() => ({ + foo: 'bar', + baz: 42, + hello: 'world', + })); + + const query = ` + query people($count: Int) { + allPeople(first: $count) { + people { + name + } + } + } + `; - function mapQueriesToProps() { - return { - people: { query, variables }, + const variables = { + count: 1, }; - }; - @connect({ mapQueriesToProps }) - class Container extends React.Component { - render() { - return ; - } - }; + const data = { + allPeople: { + people: [ + { + name: 'Luke Skywalker', + }, + ], + }, + }; - const wrapper = mount( - - - - ); - const props = wrapper.find('span').props() as any; + const networkInterface = mockNetworkInterface({ + request: { query, variables }, + result: { data }, + }); - expect(props.people).to.exist; - expect(props.people.loading).to.be.true; - }); + const client = new ApolloClient({ + networkInterface, + }); - it('can use passed props as part of the query', () => { - const store = createStore(() => ({ - foo: 'bar', - baz: 42, - hello: 'world', - })); + function mapQueriesToProps({ ownProps }) { + expect(ownProps.passedCountProp).to.equal(2); + return { + people: { + query, + variables: { + count: ownProps.passedCountProp, + }, + }, + }; + }; - const query = ` - query people($count: Int) { - allPeople(first: $count) { - people { - name + @connect({ mapQueriesToProps }) + class Container extends React.Component { + render() { + return ; + } + }; + + const wrapper = mount( + + + + ); + + const props = wrapper.find('span').props() as any; + + expect(props.people).to.exist; + expect(props.people.loading).to.be.true; + }); + + it('can use the redux state as part of the query', () => { + const store = createStore(() => ({ + foo: 'bar', + baz: 42, + hello: 'world', + })); + + const query = ` + query people($count: Int) { + allPeople(first: $count) { + people { + name + } } } - } - `; + `; - const variables = { - count: 1, - }; + const variables = { + count: 1, + }; - const data = { - allPeople: { - people: [ - { - name: 'Luke Skywalker', + const data = { + allPeople: { + people: [ + { + name: 'Luke Skywalker', + }, + ], + }, + }; + + + const networkInterface = mockNetworkInterface({ + request: { query, variables }, + result: { data }, + }); + + const client = new ApolloClient({ + networkInterface, + }); + + function mapQueriesToProps({ state }) { + expect(state.hello).to.equal('world'); + return { + people: { + query, + variables: { + count: 1, + }, }, - ], - }, - }; + }; + }; + + @connect({ mapQueriesToProps }) + class Container extends React.Component { + render() { + return ; + } + }; + const wrapper = mount( + + + + ); - const networkInterface = mockNetworkInterface({ - request: { query, variables }, - result: { data }, - }); + const props = wrapper.find('span').props() as any; - const client = new ApolloClient({ - networkInterface, + expect(props.people).to.exist; + expect(props.people.loading).to.be.true; }); - function mapQueriesToProps({ ownProps }) { - expect(ownProps.passedCountProp).to.equal(2); - return { - people: { - query, - variables: { - count: ownProps.passedCountProp, - }, + it('allows for multiple queries', () => { + const store = createStore(() => ({ + foo: 'bar', + baz: 42, + hello: 'world', + })); + + const peopleQuery = ` + query people($count: Int) { + allPeople(first: $count) { + people { + name + } + } + } + `; + + const peopleData = { + allPeople: { + people: [ + { + name: 'Luke Skywalker', + }, + ], }, }; - }; - @connect({ mapQueriesToProps }) - class Container extends React.Component { - render() { - return ; - } - }; + // const shipData = { + // allStarships: { + // starships: [ + // { + // name: 'CR90 corvette', + // }, + // ], + // }, + // }; + + const shipQuery = ` + query starships($count: Int) { + allStarships(first: $count) { + starships { + name + } + } + } + `; - const wrapper = mount( - - - - ); + const variables = { count: 1 }; - const props = wrapper.find('span').props() as any; + const networkInterface = mockNetworkInterface({ + request: { query: peopleQuery, variables }, + result: { data: peopleData }, + }); - expect(props.people).to.exist; - expect(props.people.loading).to.be.true; - }); + const client = new ApolloClient({ + networkInterface, + }); - it('can use the redux state as part of the query', () => { - const store = createStore(() => ({ - foo: 'bar', - baz: 42, - hello: 'world', - })); + function mapQueriesToProps() { + return { + people: { query: peopleQuery, variables }, + ships: { query: shipQuery, variables }, + }; + }; - const query = ` - query people($count: Int) { - allPeople(first: $count) { - people { - name - } + @connect({ mapQueriesToProps }) + class Container extends React.Component { + render() { + return ; } - } - `; + }; - const variables = { - count: 1, - }; + const wrapper = mount( + + + + ); - const data = { - allPeople: { - people: [ - { - name: 'Luke Skywalker', - }, - ], - }, - }; + const props = wrapper.find('span').props() as any; + expect(props.people).to.exist; + expect(props.people.loading).to.be.true; - const networkInterface = mockNetworkInterface({ - request: { query, variables }, - result: { data }, + expect(props.ships).to.exist; + expect(props.ships.loading).to.be.true; }); - const client = new ApolloClient({ - networkInterface, - }); - function mapQueriesToProps({ state }) { - expect(state.hello).to.equal('world'); - return { - people: { - query, - variables: { - count: 1, - }, + it('should update the props of the child component when data is returned', (done) => { + const store = createStore(() => ({ })); + + const query = ` + query people { + luke: allPeople(first: 1) { + people { + name + } + } + } + `; + + const data = { + luke: { + people: [ + { + name: 'Luke Skywalker', + }, + ], }, }; - }; - @connect({ mapQueriesToProps }) - class Container extends React.Component { - render() { - return ; - } - }; + const networkInterface = mockNetworkInterface({ + request: { query }, + result: { data }, + }); - const wrapper = mount( - - - - ); + const client = new ApolloClient({ + networkInterface, + }); - const props = wrapper.find('span').props() as any; + function mapQueriesToProps() { + return { + luke: { query }, + }; + }; - expect(props.people).to.exist; - expect(props.people.loading).to.be.true; + @connect({ mapQueriesToProps }) + class Container extends React.Component { + componentDidUpdate(prevProps) { + expect(prevProps.luke.loading).to.be.true; + expect(this.props.luke.result).to.deep.equal(data); + done(); + } + render() { + return ; + } + }; + + mount( + + + + ); + }); }); - it('allows for multiple queries', () => { - const store = createStore(() => ({ - foo: 'bar', - baz: 42, - hello: 'world', - })); + describe('mutations', () => { + it('should bind mutation data to props', () => { + const store = createStore(() => ({ + foo: 'bar', + baz: 42, + hello: 'world', + })); + + const mutation = ` + mutation makeListPrivate($listId: ID!) { + makeListPrivate(id: $listId) + } + `; - const peopleQuery = ` - query people($count: Int) { - allPeople(first: $count) { - people { - name - } + const variables = { + listId: '1', + }; + + const data = { + makeListPrivate: true, + }; + + const networkInterface = mockNetworkInterface({ + request: { query: mutation, variables }, + result: { data }, + }); + + const client = new ApolloClient({ + networkInterface, + }); + + function mapMutationsToProps() { + return { + makeListPrivate: () => ({ + mutation, + variables, + }), + }; + }; + + @connect({ mapMutationsToProps }) + class Container extends React.Component { + render() { + return ; } - } - `; + }; - const peopleData = { - allPeople: { - people: [ - { - name: 'Luke Skywalker', - }, - ], - }, - }; + const wrapper = mount( + + + + ); - // const shipData = { - // allStarships: { - // starships: [ - // { - // name: 'CR90 corvette', - // }, - // ], - // }, - // }; - - const shipQuery = ` - query starships($count: Int) { - allStarships(first: $count) { - starships { - name - } + const props = wrapper.find('span').props() as any; + + expect(props.makeListPrivate).to.exist; + expect(props.makeListPrivate.loading).to.be.true; + }); + + it('should bind multiple mutation keys to props', () => { + const store = createStore(() => ({ + foo: 'bar', + baz: 42, + hello: 'world', + })); + + const mutation1 = ` + mutation makeListPrivate($listId: ID!) { + makeListPrivate(id: $listId) } - } - `; + `; - const variables = { count: 1 }; + const mutation2 = ` + mutation makeListReallyPrivate($listId: ID!) { + makeListReallyPrivate(id: $listId) + } + `; - const networkInterface = mockNetworkInterface({ - request: { query: peopleQuery, variables }, - result: { data: peopleData }, + const data = { + makeListPrivate: true, + }; + + const networkInterface = mockNetworkInterface({ + request: { query: mutation1 }, + result: { data }, + }); + + const client = new ApolloClient({ + networkInterface, + }); + + function mapMutationsToProps() { + return { + makeListPrivate: () => ({ + mutation1, + }), + makeListReallyPrivate: () => ({ + mutation2, + }), + }; + }; + + @connect({ mapMutationsToProps }) + class Container extends React.Component { + render() { + return ; + } + }; + + const wrapper = mount( + + + + ); + + const props = wrapper.find('span').props() as any; + + expect(props.makeListPrivate).to.exist; + expect(props.makeListPrivate.loading).to.be.true; + expect(props.makeListReallyPrivate).to.exist; + expect(props.makeListReallyPrivate.loading).to.be.true; }); - const client = new ApolloClient({ - networkInterface, + it('should bind mutation handler to `props.mutations[key]`', () => { + const store = createStore(() => ({ + foo: 'bar', + baz: 42, + hello: 'world', + })); + + const mutation = ` + mutation makeListPrivate($listId: ID!) { + makeListPrivate(id: $listId) + } + `; + + const variables = { + listId: '1', + }; + + const data = { + makeListPrivate: true, + }; + + const networkInterface = mockNetworkInterface({ + request: { query: mutation, variables }, + result: { data }, + }); + + const client = new ApolloClient({ + networkInterface, + }); + + function mapMutationsToProps() { + return { + makeListPrivate: () => ({ + mutation, + }), + }; + }; + + @connect({ mapMutationsToProps }) + class Container extends React.Component { + render() { + return ; + } + }; + + const wrapper = mount( + + + + ); + + const props = wrapper.find('span').props() as any; + + expect(props.makeListPrivate).to.exist; + expect(props.makeListPrivate.loading).to.be.true; + + expect(props.mutations).to.exist; + expect(props.mutations.makeListPrivate).to.exist; + expect(props.mutations.makeListPrivate).to.be.instanceof(Function); }); - function mapQueriesToProps() { - return { - people: { query: peopleQuery, variables }, - ships: { query: shipQuery, variables }, + it('should update the props of the child component when data is returned', (done) => { + const store = createStore(() => ({ })); + + const mutation = ` + mutation makeListPrivate($listId: ID!) { + makeListPrivate(id: $listId) + } + `; + + const variables = { + listId: '1', }; - }; - @connect({ mapQueriesToProps }) - class Container extends React.Component { - render() { - return ; - } - }; + const data = { + makeListPrivate: true, + }; - const wrapper = mount( - - - - ); + const networkInterface = mockNetworkInterface({ + request: { query: mutation, variables }, + result: { data }, + }); + + const client = new ApolloClient({ + networkInterface, + }); + + function mapMutationsToProps() { + return { + makeListPrivate: () => ({ + mutation, + variables, + }), + }; + }; - const props = wrapper.find('span').props() as any; + @connect({ mapMutationsToProps }) + class Container extends React.Component { + componentDidMount() { + // call the muation + this.props.mutations.makeListPrivate(); + } - expect(props.people).to.exist; - expect(props.people.loading).to.be.true; + componentDidUpdate(prevProps) { + expect(prevProps.makeListPrivate.loading).to.be.true; + expect(this.props.makeListPrivate.result).to.deep.equal(data); + done(); + } - expect(props.ships).to.exist; - expect(props.ships.loading).to.be.true; - }); + render() { + return ; + } + }; + mount( + + + + ); + }); - it('should update the props of the child component when data is returned', (done) => { - const store = createStore(() => ({ })); + it('can use passed props as part of the mutation', (done) => { + const store = createStore(() => ({ + foo: 'bar', + baz: 42, + hello: 'world', + })); - const query = ` - query people { - luke: allPeople(first: 1) { - people { - name - } + const mutation = ` + mutation makeListPrivate($listId: ID!) { + makeListPrivate(id: $listId) } - } - `; + `; - const data = { - luke: { - people: [ - { - name: 'Luke Skywalker', + const variables = { + listId: '1', + }; + + const data = { + makeListPrivate: true, + }; + + const networkInterface = mockNetworkInterface({ + request: { query: mutation, variables }, + result: { data }, + }); + + const client = new ApolloClient({ + networkInterface, + }); + + function mapMutationsToProps({ ownProps }) { + expect(ownProps.listId).to.equal('1'); + return { + makeListPrivate: () => { + return { + mutation, + variables: { + listId: ownProps.listId, + }, + }; }, - ], - }, - }; + }; + }; - const networkInterface = mockNetworkInterface({ - request: { query }, - result: { data }, + @connect({ mapMutationsToProps }) + class Container extends React.Component { + componentDidMount() { + // call the muation + this.props.mutations.makeListPrivate(); + } + + componentDidUpdate(prevProps) { + expect(prevProps.makeListPrivate.loading).to.be.true; + expect(this.props.makeListPrivate.result).to.deep.equal(data); + done(); + } + + render() { + return ; + } + }; + + mount( + + + + ); }); - const client = new ApolloClient({ - networkInterface, + it('can use the redux store as part of the mutation', (done) => { + const store = createStore(() => ({ + foo: 'bar', + baz: 42, + listId: '1', + })); + + const mutation = ` + mutation makeListPrivate($listId: ID!) { + makeListPrivate(id: $listId) + } + `; + + const variables = { + listId: '1', + }; + + const data = { + makeListPrivate: true, + }; + + const networkInterface = mockNetworkInterface({ + request: { query: mutation, variables }, + result: { data }, + }); + + const client = new ApolloClient({ + networkInterface, + }); + + function mapMutationsToProps({ state }) { + expect(state.listId).to.equal('1'); + return { + makeListPrivate: () => { + return { + mutation, + variables: { + listId: state.listId, + }, + }; + }, + }; + }; + + @connect({ mapMutationsToProps }) + class Container extends React.Component { + componentDidMount() { + // call the muation + this.props.mutations.makeListPrivate(); + } + + componentDidUpdate(prevProps) { + expect(prevProps.makeListPrivate.loading).to.be.true; + expect(this.props.makeListPrivate.result).to.deep.equal(data); + done(); + } + + render() { + return ; + } + }; + + mount( + + + + ); }); - function mapQueriesToProps() { - return { - luke: { query }, + it('should allow passing custom arugments to mutation handle', (done) => { + const store = createStore(() => ({ })); + + const mutation = ` + mutation makeListPrivate($listId: ID!) { + makeListPrivate(id: $listId) + } + `; + + const variables = { + listId: '1', }; - }; - @connect({ mapQueriesToProps }) - class Container extends React.Component { - componentDidUpdate(prevProps) { - expect(prevProps.luke.loading).to.be.true; - expect(this.props.luke.result).to.deep.equal(data); - done(); - } - render() { - return ; - } - }; + const data = { + makeListPrivate: true, + }; + + const networkInterface = mockNetworkInterface({ + request: { query: mutation, variables }, + result: { data }, + }); + + const client = new ApolloClient({ + networkInterface, + }); + + function mapMutationsToProps() { + return { + makeListPrivate: (listId) => { + // expect(listId).to.equal('1'); + return { + mutation, + variables: { + listId, + }, + }; + }, + }; + }; + + @connect({ mapMutationsToProps }) + class Container extends React.Component { + componentDidMount() { + // call the muation + this.props.mutations.makeListPrivate('1'); + } + + componentDidUpdate(prevProps) { + expect(prevProps.makeListPrivate.loading).to.be.true; + expect(this.props.makeListPrivate.result).to.deep.equal(data); + done(); + } + + render() { + return ; + } + }; + + mount( + + + + ); + }); - mount( - - - - ); }); }); }); From 0d636ddcdaea12e7a35cbda2c3206d70af2e3175 Mon Sep 17 00:00:00 2001 From: James Baxley Date: Sat, 16 Apr 2016 01:47:37 -0400 Subject: [PATCH 09/16] add first run at reading queries from cache --- src/connect.tsx | 32 +++++++++++++++------ test/connect.tsx | 73 +++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 95 insertions(+), 10 deletions(-) diff --git a/src/connect.tsx b/src/connect.tsx index c5dbc9b830..d4e7e60c89 100644 --- a/src/connect.tsx +++ b/src/connect.tsx @@ -29,7 +29,7 @@ import { Store, } from 'redux'; -import ApolloClient from 'apollo-client'; +import ApolloClient, { readQueryFromStore } from 'apollo-client'; import { GraphQLResult, @@ -194,7 +194,8 @@ export default function connect(opts?: ConnectOptions) { } subscribeToAllQueries(props: any, state: any) { - const { watchQuery } = this.client; + const { watchQuery, reduxRootKey } = this.client; + const { store } = this; const queryHandles = mapQueriesToProps({ state, @@ -209,11 +210,27 @@ export default function connect(opts?: ConnectOptions) { continue; } - const handle = watchQuery(queryHandles[key]); + const { query, variables } = queryHandles[key]; - // XXX preload data from store - // bind key to state for updating - this.data[key] = defaultQueryData; + const handle = watchQuery({ query, variables }); + + // rudimentary way to manually check cache + let queryData = defaultQueryData as any; + try { + const result = readQueryFromStore({ + store: store.getState()[reduxRootKey].data, + query, + variables, + }); + + queryData = { + error: null, + loading: false, + result, + }; + } catch (e) {/* tslint */} + + this.data[key] = queryData; this.handleQueryData(handle, key); } @@ -321,8 +338,6 @@ export default function connect(opts?: ConnectOptions) { }; return (...args) => { - // XXX should we pass the client as `this` to the mutation method? - // it could be useful for looking up specific apollo info to shape a mutation? const { mutation, variables } = method.apply(this.client, args); return mutate({ mutation, variables }) .then(forceRender) @@ -351,7 +366,6 @@ export default function connect(opts?: ConnectOptions) { query: this.client.query, } as any; - // XXX add in props.mutations if they are needed if (Object.keys(mutations).length) { clientProps.mutations = mutations; } diff --git a/test/connect.tsx b/test/connect.tsx index dd3215e5d5..af4b67038e 100644 --- a/test/connect.tsx +++ b/test/connect.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import * as chai from 'chai'; import { mount } from 'enzyme'; -import { createStore } from 'redux'; +import { createStore, combineReducers, applyMiddleware } from 'redux'; import { connect as ReactReduxConnect } from 'react-redux'; import assign = require('object-assign'); // import { spy } from 'sinon'; @@ -752,6 +752,77 @@ describe('connect', () => { ); }); + + it('should prefill any data already in the store', (done) => { + + const query = ` + query people { + allPeople(first: 1) { + people { + name + } + } + } + `; + + const data = { + allPeople: { + people: [ + { + name: 'Luke Skywalker', + }, + ], + }, + }; + + const networkInterface = mockNetworkInterface({ + request: { query }, + result: { data }, + }); + + const client = new ApolloClient({ + networkInterface, + }); + + const reducer = client.reducer() as any; + + const store = createStore( + combineReducers({ + apollo: reducer, + }), + applyMiddleware(client.middleware()) + ); + + // we prefill the store with a query + client.query({ query }) + .then(() => { + function mapQueriesToProps() { + return { + people: { query }, + }; + }; + + @connect({ mapQueriesToProps }) + class Container extends React.Component { + render() { + return ; + } + }; + + const wrapper = mount( + + + + ); + + const props = wrapper.find('span').props() as any; + + expect(props.people).to.exist; + expect(props.people.loading).to.be.false; + expect(props.people.result).to.deep.equal(data); + done(); + }); + }); }); describe('mutations', () => { From ed414bb1b19d2d9863568fb5d01a2ddee69b92f9 Mon Sep 17 00:00:00 2001 From: James Baxley Date: Sat, 16 Apr 2016 02:17:04 -0400 Subject: [PATCH 10/16] readme update --- README.md | 95 ++++++++++++++++++++++++++++++++----------------------- 1 file changed, 55 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 4754940933..29c34d5fce 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,10 @@ Use your GraphQL server data in your React components, with the Apollo Client. -## API sketch - -I'd like to base this very heavily on the [`react-redux` API](https://github.com/reactjs/react-redux/blob/master/docs/api.md#api) and have the same parts - a `Provider` component and a `connect` function. Ideally, if you are using Apollo and Redux together, you don't have to nest calls to providers and containers - they should just work together. So, here we go! +- [Provider](#Provider) +- [connect](#connect) +- [Additional Props](#Additional Props) +- [Using in concert with Redux:](#Using in concert with Redux:) ### Provider @@ -54,7 +55,7 @@ ReactDOM.render( ) ``` -Note that this design calls it just `Provider`, which is appropriate because in the base case you can use it instead of the Redux provider. +The wrapper is called `Provider` because in the base case you can use it instead of the Redux provider or you can use it as an Apollo enhanced Redux Provider. ### connect @@ -72,9 +73,9 @@ import { connect } from 'apollo-react'; import Category from '../components/Category'; -function mapQueriesToProps({ watchQuery, ownProps, state }) { +function mapQueriesToProps({ ownProps, state }) { return { - category: watchQuery({ + category: { query: ` query getCategory($categoryId: Int!) { category(id: $categoryId) { @@ -88,44 +89,42 @@ function mapQueriesToProps({ watchQuery, ownProps, state }) { }, forceFetch: false, returnPartialData: true, - }) - } -} + }, + }; +}; -function mapMutationsToProps({ mutate, ownProps, state }) { +function mapMutationsToProps({ ownProps, state }) { return { - onPostReply(raw) { - return mutate({ - mutation: ` - mutation postReply( - $topic_id: ID! - $category_id: ID! - $raw: String! + postReply: (raw) => ({ + mutation: ` + mutation postReply( + $topic_id: ID! + $category_id: ID! + $raw: String! + ) { + createPost( + topic_id: $topic_id + category: $category_id + raw: $raw ) { - createPost( - topic_id: $topic_id - category: $category_id - raw: $raw - ) { - id - cooked - } + id + cooked } - `, - variables: { - // Use the container component's props - topic_id: ownProps.topic_id, + } + `, + variables: { + // Use the container component's props + topic_id: ownProps.topic_id, - // Use the redux state - category_id: state.selectedCategory, + // Use the redux state + category_id: state.selectedCategory, - // Use an argument passed from the callback - raw, - } - }); - } - } -} + // Use an argument passed from the triggering of the mutation + raw, + } + }), + }; +}; const CategoryWithData = connect({ mapQueriesToProps, @@ -135,7 +134,7 @@ const CategoryWithData = connect({ export default CategoryWithData; ``` -Note that `watchQuery` takes the same arguments as [`ApolloClient#watchQuery`](http://docs.apollostack.com/apollo-client/index.html#watchQuery). In this case, the `Category` component will get a prop called `category`, which has the following keys: +Each key on the object returned by mapQueriesToProps should be made up of the same possible arguments as [`ApolloClient#watchQuery`](http://docs.apollostack.com/apollo-client/index.html#watchQuery). In this case, the `Category` component will get a prop called `category`, which has the following keys: ```js { @@ -145,7 +144,23 @@ Note that `watchQuery` takes the same arguments as [`ApolloClient#watchQuery`](h } ``` -Using in concert with Redux: +mapMutationsToProps returns an object made up of keys and values that are custom functions to call the mutation. These can be used in children components (for instance, on a event handler) to trigger the mutation. The resulting function must return the same possible arguents as [`ApolloClient#mutate`](http://docs.apollostack.com/apollo-client/index.html#mutate). In this case, the `Category` component will get a prop called `postReply`, which has the following keys: + +```js +{ + loading: boolean, + error: Error, + result: GraphQLResult, +} +``` + +The `Category` component will also get a prop of `mutations` that will have a key of `postReply`. This key is the method that triggers the mutation and can take custom arguments (e.g. `this.props.mutations.postReply('Apollo and React are really great!')). These arguments are passed to the method that creates the mutation. + +### Additional Props + +Redux's connect will pass `dispatch` as a prop unless action creators are passed using `mapDisptachToProps`. Likewise, the Apollo connect exposes part of the apollo-client api to props under the keys `query` and `mutate`. These correspond to the Apollo methods and can be used for custom needs outside of the ability of the wrapper component. + +### Using in concert with Redux ```js // ... same as above From ea7ab1d24a02fea4b067b241238a4e02b80da239 Mon Sep 17 00:00:00 2001 From: James Baxley Date: Sat, 16 Apr 2016 02:22:54 -0400 Subject: [PATCH 11/16] readme links --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 29c34d5fce..7029217f53 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,10 @@ Use your GraphQL server data in your React components, with the Apollo Client. -- [Provider](#Provider) +- [Provider](#provider) - [connect](#connect) -- [Additional Props](#Additional Props) -- [Using in concert with Redux:](#Using in concert with Redux:) +- [Additional Props](#additional-props) +- [Using in concert with Redux](#using-in-concert-with-redux) ### Provider From 7dd0e64c80308f4b1224fa27d76b2dca6b0d3305 Mon Sep 17 00:00:00 2001 From: James Baxley Date: Sat, 16 Apr 2016 02:24:39 -0400 Subject: [PATCH 12/16] readme formatting --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7029217f53..ef7dab95be 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,7 @@ Each key on the object returned by mapQueriesToProps should be made up of the sa } ``` -mapMutationsToProps returns an object made up of keys and values that are custom functions to call the mutation. These can be used in children components (for instance, on a event handler) to trigger the mutation. The resulting function must return the same possible arguents as [`ApolloClient#mutate`](http://docs.apollostack.com/apollo-client/index.html#mutate). In this case, the `Category` component will get a prop called `postReply`, which has the following keys: +`mapMutationsToProps` returns an object made up of keys and values that are custom functions to call the mutation. These can be used in children components (for instance, on a event handler) to trigger the mutation. The resulting function must return the same possible arguents as [`ApolloClient#mutate`](http://docs.apollostack.com/apollo-client/index.html#mutate). In this case, the `Category` component will get a prop called `postReply`, which has the following keys: ```js { @@ -154,7 +154,7 @@ mapMutationsToProps returns an object made up of keys and values that are custom } ``` -The `Category` component will also get a prop of `mutations` that will have a key of `postReply`. This key is the method that triggers the mutation and can take custom arguments (e.g. `this.props.mutations.postReply('Apollo and React are really great!')). These arguments are passed to the method that creates the mutation. +The `Category` component will also get a prop of `mutations` that will have a key of `postReply`. This key is the method that triggers the mutation and can take custom arguments (e.g. `this.props.mutations.postReply('Apollo and React are really great!')`). These arguments are passed to the method that creates the mutation. ### Additional Props From db22eb32989d22276880652b852a73c70982120d Mon Sep 17 00:00:00 2001 From: James Baxley Date: Sat, 16 Apr 2016 02:25:31 -0400 Subject: [PATCH 13/16] remove empty file --- src/utils.ts | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 src/utils.ts diff --git a/src/utils.ts b/src/utils.ts deleted file mode 100644 index e69de29bb2..0000000000 From fc049e2f91c627547cef5c29efdf5b72f12bf9e2 Mon Sep 17 00:00:00 2001 From: James Baxley Date: Sat, 16 Apr 2016 02:27:10 -0400 Subject: [PATCH 14/16] merge data instead of replace on error --- src/connect.tsx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/connect.tsx b/src/connect.tsx index d4e7e60c89..9226fd9b65 100644 --- a/src/connect.tsx +++ b/src/connect.tsx @@ -253,23 +253,21 @@ export default function connect(opts?: ConnectOptions) { // has been recieved // XXX use newer subscribe method - // XXX merge this.data instead of a full replace /* handle.subscribe({ onResult(({ error, data }) => { - this.data[key] = { + this.data[key] = assign(this.data[key], { loading: false, result: data, error, - } + }); }), onError((error) => { - this.data[key] = { + this.data[key] = assign(this.data[key], { loading: false, - result: null, // error, - } + }); }), }) From ef3ad797bae8d37ee6f748ac588d247a6b81095e Mon Sep 17 00:00:00 2001 From: James Baxley Date: Sat, 16 Apr 2016 02:29:23 -0400 Subject: [PATCH 15/16] update deps --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index e07e6163bc..e43e5b2ef4 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,8 @@ "license": "MIT", "peerDependencies": { "react": "^0.14.0 || ^15.0.0-0", - "redux": "^2.0.0 || ^3.0.0" + "redux": "^2.0.0 || ^3.0.0", + "apollo-client": "0.0.5" }, "devDependencies": { "apollo-client": "0.0.5", From 99194dbd0d2e02583be9c4537b530536cfc1d600 Mon Sep 17 00:00:00 2001 From: James Baxley Date: Sat, 16 Apr 2016 02:44:28 -0400 Subject: [PATCH 16/16] update to newer subscription method --- src/connect.tsx | 29 +++++++---------------------- 1 file changed, 7 insertions(+), 22 deletions(-) diff --git a/src/connect.tsx b/src/connect.tsx index 9226fd9b65..3f202cbbcf 100644 --- a/src/connect.tsx +++ b/src/connect.tsx @@ -252,30 +252,10 @@ export default function connect(opts?: ConnectOptions) { // bind each handle to updating and rerendering when data // has been recieved - // XXX use newer subscribe method - /* - - handle.subscribe({ - onResult(({ error, data }) => { - this.data[key] = assign(this.data[key], { - loading: false, - result: data, - error, - }); - }), - onError((error) => { - this.data[key] = assign(this.data[key], { - loading: false, - error, - }); - }), - }) - - */ - handle.onResult(({ error, data }) => { + const forceRender = ({ error, data }: any) => { this.data[key] = { loading: false, - result: data, + result: data || null, error, }; @@ -283,6 +263,11 @@ export default function connect(opts?: ConnectOptions) { // update state to latest of redux store this.setState(this.store.getState()); + }; + + handle.subscribe({ + onResult: forceRender, + onError(error) { forceRender({ error }); }, }); }