From 041f07472ef0eaf8167bf977eebe167ee79d2a5d Mon Sep 17 00:00:00 2001 From: James Baxley Date: Fri, 12 Aug 2016 18:37:14 -0400 Subject: [PATCH] include support for fragments, and fix possible issues with SSR --- src/graphql.tsx | 27 +++- src/parser.ts | 25 +-- src/server.ts | 2 + test/mocks/mockNetworkInterface.ts | 3 +- test/parser.ts | 16 +- test/react-web/client/graphql/fragments.tsx | 170 ++++++++++++++++++++ 6 files changed, 224 insertions(+), 19 deletions(-) create mode 100644 test/react-web/client/graphql/fragments.tsx diff --git a/src/graphql.tsx b/src/graphql.tsx index dc0374dd68..988787bd42 100644 --- a/src/graphql.tsx +++ b/src/graphql.tsx @@ -6,8 +6,8 @@ import { } from 'react'; // modules don't export ES6 modules -// import isObject = require('lodash.isobject'); import isEqual = require('lodash.isequal'); +import flatten = require('lodash.flatten'); import shallowEqual from './shallowEqual'; import invariant = require('invariant'); @@ -23,6 +23,10 @@ import ApolloClient, { readQueryFromStore, } from 'apollo-client'; +import { + createFragmentMap, +} from 'apollo-client/queries/getFromAST'; + import { ApolloError, } from 'apollo-client/errors'; @@ -55,7 +59,7 @@ import { parser, DocumentType } from './parser'; export declare interface MutationOptions { variables?: Object; resultBehaviors?: MutationBehavior[]; - fragments?: FragmentDefinition[]; + fragments?: FragmentDefinition[] | FragmentDefinition[][]; optimisticResponse?: Object; updateQueries?: MutationQueryReducersMap; } @@ -67,7 +71,7 @@ export declare interface QueryOptions { returnPartialData?: boolean; noFetch?: boolean; pollInterval?: number; - fragments?: FragmentDefinition[]; + fragments?: FragmentDefinition[] | FragmentDefinition[][]; skip?: boolean; } @@ -148,6 +152,16 @@ export default function graphql( const graphQLDisplayName = `Apollo(${getDisplayName(WrappedComponent)})`; + function calculateFragments(opts) { + if (opts.fragments || operation.fragments.length) { + if (!opts.fragments) { + opts.fragments = flatten([...operation.fragments]); + } else { + opts.fragments = flatten([...opts.fragments, ...operation.fragments]); + } + } + } + function calculateVariables(props) { const opts = mapPropsToOptions(props); if (opts.variables || !operation.variables.length) return opts.variables; @@ -179,6 +193,7 @@ export default function graphql( if (opts.ssr === false) return false; if (!opts.variables) opts.variables = calculateVariables(props); if (!opts.variables) delete opts.variables; + calculateFragments(opts); // if this query is in the store, don't block execution try { @@ -186,6 +201,7 @@ export default function graphql( store: client.store.getState()[client.reduxRootKey].data, query: opts.query, variables: opts.variables, + fragmentMap: createFragmentMap(opts.fragments), }); return false; } catch (e) {/* tslint:disable-line */} @@ -204,6 +220,9 @@ export default function graphql( // for use with getData during SSR static fetchData = operation.type === DocumentType.Query ? fetchData : false; + // start of query composition + static fragments: FragmentDefinition[] = operation.fragments; + // react / redux and react dev tools (HMR) needs public props: any; // passed props public version: number; @@ -353,6 +372,7 @@ export default function graphql( this.unsubscribeFromQuery(); const queryOptions: WatchQueryOptions = assign({ query: document }, opts); + calculateFragments(queryOptions); const observableQuery = watchQuery(queryOptions); const { queryId } = observableQuery; @@ -503,6 +523,7 @@ export default function graphql( if (typeof opts.variables === 'undefined') delete opts.variables; (opts as any).mutation = document; + calculateFragments(opts); return this.client.mutate((opts as any)); }; diff --git a/src/parser.ts b/src/parser.ts index 0947b3df29..cdf6f2fc63 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -2,8 +2,11 @@ import { Document, VariableDefinition, OperationDefinition, + FragmentDefinition, } from 'graphql'; +import { createFragment } from 'apollo-client'; + import invariant = require('invariant'); export enum DocumentType { @@ -15,6 +18,7 @@ export interface IDocumentDefinition { type: DocumentType; name: string; variables: VariableDefinition[]; + fragments: FragmentDefinition[]; } // the parser is mainly a safety check for the HOC @@ -37,13 +41,10 @@ export function parser(document: Document): IDocumentDefinition { (x: OperationDefinition) => x.kind === 'FragmentDefinition' ); - if (fragments.length) { - invariant(fragments.length === 0, - // tslint:disable-line - `Fragments should be passed to react-apollo as 'fragments' in the passed options. See http://docs.apollostack.com/apollo-client/fragments.html for more information about fragments when using apollo` - ); - } - + fragments = createFragment({ + kind: 'Document', + definitions: [...fragments], + }); queries = document.definitions.filter( (x: OperationDefinition) => x.kind === 'OperationDefinition' && x.operation === 'query' @@ -53,6 +54,12 @@ export function parser(document: Document): IDocumentDefinition { (x: OperationDefinition) => x.kind === 'OperationDefinition' && x.operation === 'mutation' ); + if (fragments.length && (!queries.length || !mutations.length)) { + invariant(true, + `Passing only a fragment to 'graphql' is not yet supported. You must include a query or mutation as well` + ); + } + if (queries.length && mutations.length) { invariant((queries.length && mutations.length), // tslint:disable-line @@ -73,6 +80,6 @@ export function parser(document: Document): IDocumentDefinition { variables = definitions[0].variableDefinitions || []; let hasName = definitions[0].name && definitions[0].name.kind === 'Name'; name = hasName ? definitions[0].name.value : 'data'; // fallback to using data if no name - - return { name, type, variables }; + fragments = fragments.length ? fragments : []; + return { name, type, variables, fragments }; } diff --git a/src/server.ts b/src/server.ts index 5aceeb4004..02a6d1f910 100644 --- a/src/server.ts +++ b/src/server.ts @@ -45,6 +45,8 @@ function getQueriesFromTree( let ComponentClass = type; let ownProps = getPropsFromChild(component); const Component = new ComponentClass(ownProps, context); + try { Component.props = ownProps; } catch(e) {} // tslint:disable-line + if (Component.componentWillMount) Component.componentWillMount(); let newContext = context; if (Component.getChildContext) newContext = assign({}, context, Component.getChildContext()); diff --git a/test/mocks/mockNetworkInterface.ts b/test/mocks/mockNetworkInterface.ts index 7c7ba91585..8bfee4d914 100644 --- a/test/mocks/mockNetworkInterface.ts +++ b/test/mocks/mockNetworkInterface.ts @@ -60,7 +60,7 @@ export class MockNetworkInterface implements NetworkInterface { const key = requestToKey(parsedRequest); if (!this.mockedResponsesByKey[key]) { - throw new Error('No more mocked responses for the query: ' + request.query); + throw new Error('No more mocked responses for the query: ' + print(request.query)); } const { result, error, delay } = this.mockedResponsesByKey[key].shift() || {} as any; @@ -82,7 +82,6 @@ export class MockNetworkInterface implements NetworkInterface { function requestToKey(request: ParsedRequest): string { const queryString = request.query && print(request.query); - return JSON.stringify({ variables: request.variables, debugName: request.debugName, diff --git a/test/parser.ts b/test/parser.ts index d3fda9ccf6..e438f6cacd 100644 --- a/test/parser.ts +++ b/test/parser.ts @@ -13,12 +13,18 @@ describe('parser', () => { // expect(parser('{ user { name } }')).to.throw(); // }); - it('should error if fragments are included in the operation', () => { - const query = gql`fragment bookInfo on Book { name }`; + it('should dynamically create `FragmentDefinition` for included fragments', () => { + const query = gql` + fragment bookInfo on Book { name } + query getBook { + books { + ...bookInfo + } + } + `; - try { parser(query); } catch (e) { - expect(e).to.match(/Invariant Violation: Fragments/); - } + const parsed = parser(query); + expect(parsed.fragments.length).to.equal(1); }); it('should error if both a query and a mutation is present', () => { diff --git a/test/react-web/client/graphql/fragments.tsx b/test/react-web/client/graphql/fragments.tsx new file mode 100644 index 0000000000..4a3c1f5e7e --- /dev/null +++ b/test/react-web/client/graphql/fragments.tsx @@ -0,0 +1,170 @@ + +import * as React from 'react'; +import * as chai from 'chai'; +import { mount } from 'enzyme'; +import gql from 'graphql-tag'; + +import ApolloClient, { createFragment } 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 mockNetworkInterface from '../../../mocks/mockNetworkInterface'; +import { + // Passthrough, + ProviderMock, +} from '../../../mocks/components'; + +import graphql from '../../../../src/graphql'; + +describe('fragments', () => { + + // XXX in a later version, we should support this for composition + it('throws if you only pass a fragment', (done) => { + const query = gql` + fragment Failure on PeopleConnection { people { name } } + `; + const data = { allPeople: { people: [ { name: 'Luke Skywalker' } ] } }; + const networkInterface = mockNetworkInterface({ request: { query }, result: { data } }); + const client = new ApolloClient({ networkInterface }); + + try { + @graphql(query) + class Container extends React.Component { + componentWillReceiveProps(props) { + expect(props.data.loading).to.be.false; + expect(props.data.allPeople).to.deep.equal(data.allPeople); + done(); + } + render() { + return null; + } + }; + + mount() + done(new Error('This should throw')) + } catch (e) { + // expect(e).to.match(/Invariant Violation/); + done(); + } + }); + + it('correctly fetches a query with inline fragments', (done) => { + const query = gql` + query people { allPeople(first: 1) { ...person } } + fragment person on PeopleConnection { people { name } } + `; + const data = { allPeople: { people: [ { name: 'Luke Skywalker' } ] } }; + const networkInterface = mockNetworkInterface({ request: { query }, result: { data } }); + const client = new ApolloClient({ networkInterface }); + + @graphql(query) + class Container extends React.Component { + componentWillReceiveProps(props) { + expect(props.data.loading).to.be.false; + expect(props.data.allPeople).to.deep.equal(data.allPeople); + done(); + } + render() { + return null; + } + }; + + expect((Container as any).fragments.length).to.equal(1); + + mount(); + }); + + it('correctly merges a query with inline fragments and passed fragments', (done) => { + const query = gql` + query peopleAndShips { + allPeople(first: 1) { ...Person } + allShips(first: 1) { ...ships } + } + fragment Person on PeopleConnection { people { name } } + `; + const shipFragment = createFragment(gql` + fragment ships on ShipsConnection { starships { name } } + `); + + const mockedQuery = gql` + query peopleAndShips { + allPeople(first: 1) { ...Person } + allShips(first: 1) { ...ships } + } + fragment Person on PeopleConnection { people { name } } + fragment ships on ShipsConnection { starships { name } } + `; + + const data = { + allPeople: { people: [ { name: 'Luke Skywalker' } ] }, + allShips: { starships: [ { name: 'CR90 corvette' } ] }, + }; + const networkInterface = mockNetworkInterface( + { request: { query: mockedQuery }, result: { data } } + ); + const client = new ApolloClient({ networkInterface }); + + @graphql(query, { + options: () => ({ fragments: [shipFragment]}) + }) + class Container extends React.Component { + componentWillReceiveProps(props) { + expect(props.data.loading).to.be.false; + expect(props.data.allPeople).to.deep.equal(data.allPeople); + expect(props.data.allShips).to.deep.equal(data.allShips); + done(); + } + render() { + return null; + } + }; + + expect((Container as any).fragments.length).to.equal(1); + + mount(); + }); + + it('correctly allows for passed fragments', (done) => { + const query = gql` + query ships { allShips(first: 1) { ...Ships } } + `; + const shipFragment = createFragment(gql` + fragment Ships on ShipsConnection { starships { name } } + `); + + const mockedQuery = gql` + query ships { allShips(first: 1) { ...Ships } } + fragment Ships on ShipsConnection { starships { name } } + `; + + const data = { + allShips: { starships: [ { name: 'CR90 corvette' } ] }, + }; + const networkInterface = mockNetworkInterface( + { request: { query: mockedQuery }, result: { data } } + ); + const client = new ApolloClient({ networkInterface }); + + @graphql(query, { + options: () => ({ fragments: [shipFragment]}) + }) + class Container extends React.Component { + componentWillReceiveProps(props) { + expect(props.data.loading).to.be.false; + expect(props.data.allShips).to.deep.equal(data.allShips); + done(); + } + render() { + return null; + } + }; + + mount(); + }); + + +});