diff --git a/Changelog.md b/Changelog.md index 0e6df33906..190699864e 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,6 +1,7 @@ # Change log ### vNext +* Added `` component [#1520](https://github.com/apollographql/react-apollo/pull/1520) * HoC `props` result-mapping function now receives prior return value as second argument. * Fix errorPolicy when 'all' not passing data and errors diff --git a/package.json b/package.json index 98040f0f8d..b3f2e06549 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "bundlesize": [ { "path": "./lib/react-apollo.browser.umd.js", - "maxSize": "6.5 KB" + "maxSize": "7 KB" } ], "lint-staged": { diff --git a/src/Mutation.tsx b/src/Mutation.tsx new file mode 100644 index 0000000000..c91901518d --- /dev/null +++ b/src/Mutation.tsx @@ -0,0 +1,270 @@ +import * as React from 'react'; +import * as PropTypes from 'prop-types'; +import ApolloClient, { PureQueryOptions, ApolloError } from 'apollo-client'; +import { DataProxy } from 'apollo-cache'; +const invariant = require('invariant'); +import { DocumentNode, GraphQLError } from 'graphql'; +const shallowEqual = require('fbjs/lib/shallowEqual'); + +import { OperationVariables } from './types'; +import { parser, DocumentType } from './parser'; + +export interface MutationResult> { + data?: TData; + error?: ApolloError; + loading?: boolean; +} +export interface MutationContext { + client: ApolloClient; +} + +export interface ExecutionResult> { + data?: T; + extensions?: Record; + errors?: GraphQLError[]; +} + +// Improved MutationUpdaterFn type, need to port them back to Apollo Client +export declare type MutationUpdaterFn< + T = { + [key: string]: any; + } +> = (proxy: DataProxy, mutationResult: FetchResult) => void; + +export declare type FetchResult< + C = Record, + E = Record +> = ExecutionResult & { + extensions?: E; + context?: C; +}; + +export declare type MutationOptions = { + variables?: TVariables; +}; + +export interface MutationProps { + mutation: DocumentNode; + optimisticResponse?: Object; + refetchQueries?: string[] | PureQueryOptions[]; + update?: MutationUpdaterFn; + children: ( + mutateFn: ( + options?: MutationOptions + ) => Promise, + result?: MutationResult + ) => React.ReactNode; + onCompleted?: (data: TData) => void; + onError?: (error: ApolloError) => void; +} + +export interface MutationState { + notCalled: boolean; + error?: ApolloError; + data?: TData; + loading?: boolean; +} + +const initialState = { + notCalled: true, +}; + +class Mutation< + TData = any, + TVariables = OperationVariables +> extends React.Component< + MutationProps, + MutationState +> { + static contextTypes = { + client: PropTypes.object.isRequired, + }; + + static propTypes = { + mutation: PropTypes.object.isRequired, + variables: PropTypes.object, + optimisticResponse: PropTypes.object, + refetchQueries: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.string), + PropTypes.arrayOf(PropTypes.object), + ]), + update: PropTypes.func, + children: PropTypes.func.isRequired, + onCompleted: PropTypes.func, + onError: PropTypes.func, + }; + + private client: ApolloClient; + private mostRecentMutationId: number; + + constructor(props: MutationProps, context: any) { + super(props, context); + + this.verifyContext(context); + this.client = context.client; + + this.verifyDocumentIsMutation(props.mutation); + + this.mostRecentMutationId = 0; + this.state = initialState; + } + + componentWillReceiveProps( + nextProps: MutationProps, + nextContext: MutationContext + ) { + if ( + shallowEqual(this.props, nextProps) && + this.client === nextContext.client + ) { + return; + } + + if (this.props.mutation !== nextProps.mutation) { + this.verifyDocumentIsMutation(nextProps.mutation); + } + + if (this.client !== nextContext.client) { + this.client = nextContext.client; + this.setState(initialState); + } + } + + render() { + const { children } = this.props; + const { loading, data, error, notCalled } = this.state; + + const result = notCalled + ? undefined + : { + loading, + data, + error, + }; + + return children(this.runMutation, result); + } + + private runMutation = (options: MutationOptions = {}) => { + this.onStartMutation(); + + const mutationId = this.generateNewMutationId(); + + return this.mutate(options) + .then(response => { + this.onCompletedMutation(response, mutationId); + return response; + }) + .catch(e => { + this.onMutationError(e, mutationId); + }); + }; + + private mutate = (options: MutationOptions) => { + const { mutation, optimisticResponse, refetchQueries, update } = this.props; + + const { variables } = options; + + return this.client.mutate({ + mutation, + variables, + optimisticResponse, + refetchQueries, + update, + }); + }; + + private onStartMutation = () => { + if (!this.state.loading) { + this.setState({ + loading: true, + error: undefined, + data: undefined, + notCalled: false, + }); + } + }; + + private onCompletedMutation = ( + response: ExecutionResult, + mutationId: number + ) => { + const { onCompleted } = this.props; + + const data = response.data as TData; + + const callOncomplete = () => { + if (onCompleted) { + onCompleted(data); + } + }; + + if (this.isMostRecentMutation(mutationId)) { + this.setState( + { + loading: false, + data, + }, + () => { + callOncomplete(); + } + ); + } else { + callOncomplete(); + } + }; + + private onMutationError = (error: ApolloError, mutationId: number) => { + const { onError } = this.props; + + let apolloError = error as ApolloError; + + const callOnError = () => { + if (onError) { + onError(apolloError); + } + }; + + if (this.isMostRecentMutation(mutationId)) { + this.setState( + { + loading: false, + error: apolloError, + }, + () => { + callOnError(); + } + ); + } else { + callOnError(); + } + }; + + private generateNewMutationId = (): number => { + this.mostRecentMutationId = this.mostRecentMutationId + 1; + return this.mostRecentMutationId; + }; + + private isMostRecentMutation = (mutationId: number) => { + return this.mostRecentMutationId === mutationId; + }; + + private verifyDocumentIsMutation = (mutation: DocumentNode) => { + const operation = parser(mutation); + invariant( + operation.type === DocumentType.Mutation, + `The component requires a graphql mutation, but got a ${ + operation.type === DocumentType.Query ? 'query' : 'subscription' + }.` + ); + }; + + private verifyContext = (context: MutationContext) => { + invariant( + !!context.client, + `Could not find "client" in the context of Mutation. Wrap the root component in an ` + ); + }; +} + +export default Mutation; diff --git a/src/browser.ts b/src/browser.ts index 05b3d73b00..2f8c132e65 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -10,6 +10,9 @@ export * from './ApolloProvider'; export { default as Query } from './Query'; export * from './Query'; +export { default as Mutation } from './Mutation'; +export * from './Mutation'; + export { default as graphql } from './graphql'; export * from './graphql'; diff --git a/test/client/Mutation.test.tsx b/test/client/Mutation.test.tsx new file mode 100644 index 0000000000..f058c0a8af --- /dev/null +++ b/test/client/Mutation.test.tsx @@ -0,0 +1,783 @@ +import * as React from 'react'; +import { mount } from 'enzyme'; +import gql from 'graphql-tag'; +import { ApolloClient } from 'apollo-client'; +import { InMemoryCache as Cache } from 'apollo-cache-inmemory'; +import { DataProxy } from 'apollo-cache'; +import { ExecutionResult } from 'graphql'; + +import { ApolloProvider, Mutation, Query } from '../../src'; +import { MockedProvider, mockSingleLink } from '../../src/test-utils'; + +import stripSymbols from '../test-utils/stripSymbols'; + +const mutation = gql` + mutation createTodo($text: String!) { + createTodo { + id + text + completed + __typename + } + __typename + } +`; + +type Data = { + createTodo: { + __typename: string; + id: string; + text: string; + completed: boolean; + }; + __typename: string; +}; + +const data: Data = { + createTodo: { + __typename: 'Todo', + id: '99', + text: 'This one was created with a mutation.', + completed: true, + }, + __typename: 'Mutation', +}; + +const data2: Data = { + createTodo: { + __typename: 'Todo', + id: '100', + text: 'This one was created with a mutation.', + completed: true, + }, + __typename: 'Mutation', +}; + +const mocks = [ + { + request: { query: mutation }, + result: { data }, + }, + { + request: { query: mutation }, + result: { data: data2 }, + }, +]; + +const cache = new Cache({ addTypename: false }); + +it('performs a mutation', done => { + let count = 0; + const Component = () => ( + + {(createTodo, result) => { + if (count === 0) { + expect(result).toBeUndefined(); + setTimeout(() => { + createTodo(); + }); + } else if (count === 1) { + expect(result).toEqual({ + loading: true, + }); + } else if (count === 2) { + expect(result).toEqual({ + loading: false, + data, + }); + done(); + } + count++; + return
; + }} + + ); + + mount( + + + + ); +}); + +it('performs a mutation with variables passed as an option', done => { + const variables = { + text: 'play tennis', + }; + + let count = 0; + const Component = () => ( + + {(createTodo, result) => { + if (count === 0) { + expect(result).toBeUndefined(); + setTimeout(() => { + createTodo({ variables }); + }); + } else if (count === 1) { + expect(result).toEqual({ + loading: true, + }); + } else if (count === 2) { + expect(result).toEqual({ + loading: false, + data, + }); + done(); + } + count++; + return
; + }} + + ); + + const mocks1 = [ + { + request: { query: mutation, variables }, + result: { data }, + }, + ]; + + mount( + + + + ); +}); + +it('only shows result for the latest mutation that is in flight', done => { + let count = 0; + + const onCompleted = (dataMutation: Data) => { + if (count === 1) { + expect(dataMutation).toEqual(data); + } else if (count === 3) { + expect(dataMutation).toEqual(data2); + done(); + } + }; + const Component = () => ( + + {(createTodo, result) => { + if (count === 0) { + expect(result).toBeUndefined(); + setTimeout(() => { + createTodo(); + createTodo(); + }); + } else if (count === 1) { + expect(result).toEqual({ + loading: true, + }); + } else if (count === 2) { + expect(result).toEqual({ + loading: false, + data: data2, + }); + } + count++; + return
; + }} + + ); + + mount( + + + + ); +}); + +it('only shows the error for the latest mutation in flight', done => { + let count = 0; + + const onError = (error: Error) => { + if (count === 1) { + expect(error).toEqual(new Error('Network error: Error 1')); + } else if (count === 3) { + expect(error).toEqual(new Error('Network error: Error 2')); + done(); + } + }; + const Component = () => ( + + {(createTodo, result) => { + if (count === 0) { + expect(result).toBeUndefined(); + setTimeout(() => { + createTodo(); + createTodo(); + }); + } else if (count === 1) { + expect(result).toEqual({ + loading: true, + }); + } else if (count === 2) { + expect(result).toEqual({ + loading: false, + data: undefined, + error: new Error('Network error: Error 2'), + }); + } + count++; + return
; + }} + + ); + + const mocksWithErrors = [ + { + request: { query: mutation }, + error: new Error('Error 2'), + }, + { + request: { query: mutation }, + error: new Error('Error 2'), + }, + ]; + + mount( + + + + ); +}); + +it('calls the onCompleted prop as soon as the mutation is complete', done => { + let onCompletedCalled = false; + + class Component extends React.Component { + state = { + mutationDone: false, + }; + + onCompleted = (mutationData: Data) => { + expect(mutationData).toEqual(data); + onCompletedCalled = true; + this.setState({ + mutationDone: true, + }); + }; + + render() { + return ( + + {(createTodo, result) => { + if (!result) { + expect(this.state.mutationDone).toBe(false); + setTimeout(() => { + createTodo(); + }); + } + if (onCompletedCalled) { + expect(this.state.mutationDone).toBe(true); + done(); + } + return null; + }} + + ); + } + } + + mount( + + + + ); +}); + +it('renders result of the children render prop', () => { + const Component = () => ( + {() =>
} + ); + + const wrapper = mount( + + + + ); + expect(wrapper.find('div').exists()).toBe(true); +}); + +it('renders an error state', done => { + let count = 0; + const Component = () => ( + + {(createTodo, result) => { + if (count === 0) { + setTimeout(() => { + createTodo(); + }); + } else if (count === 1 && result) { + expect(result.loading).toBeTruthy(); + } else if (count === 2 && result) { + expect(result.error).toEqual( + new Error('Network error: error occurred') + ); + done(); + } + count++; + return
; + }} + + ); + + const mockError = [ + { + request: { query: mutation }, + error: new Error('error occurred'), + }, + ]; + + mount( + + + + ); +}); + +it('calls the onError prop if the mutation encounters an error', done => { + let onRenderCalled = false; + + class Component extends React.Component { + state = { + mutationError: false, + }; + + onError = (error: Error) => { + expect(error).toEqual(new Error('Network error: error occurred')); + onRenderCalled = true; + this.setState({ + mutationError: true, + }); + }; + + render() { + const { mutationError } = this.state; + + return ( + + {(createTodo, result) => { + if (!result) { + expect(mutationError).toBe(false); + setTimeout(() => { + createTodo(); + }); + } + if (onRenderCalled) { + expect(mutationError).toBe(true); + done(); + } + return null; + }} + + ); + } + } + + const mockError = [ + { + request: { query: mutation }, + error: new Error('error occurred'), + }, + ]; + + mount( + + + + ); +}); + +it('returns an optimistic response', done => { + const link = mockSingleLink(...mocks); + const client = new ApolloClient({ + link, + cache, + }); + + const optimisticResponse = { + createTodo: { + id: '99', + text: 'This is an optimistic response', + completed: false, + __typename: 'Todo', + }, + __typename: 'Mutation', + }; + + let count = 0; + const Component = () => ( + + {(createTodo, result) => { + if (count === 0) { + expect(result).toBeUndefined(); + setTimeout(() => { + createTodo(); + const dataInStore = client.cache.extract(true); + expect(dataInStore['Todo:99']).toEqual( + optimisticResponse.createTodo + ); + }); + } else if (count === 1) { + expect(result).toEqual({ + loading: true, + }); + } else if (count === 2) { + expect(result).toEqual({ + loading: false, + data, + }); + done(); + } + count++; + return
; + }} + + ); + + mount( + + + + ); +}); + +it('has refetchQueries in the props', done => { + const query = gql` + query getTodo { + todo { + id + text + completed + __typename + } + __typename + } + `; + + const queryData = { + todo: { + id: '1', + text: 'todo from query', + completed: false, + __typename: 'Todo', + }, + __typename: 'Query', + }; + + const mocksWithQuery = [ + ...mocks, + // TODO: Somehow apollo-client makes 3 request + // when refetch queries is enabled?? + { + request: { query }, + result: { data: queryData }, + }, + { + request: { query }, + result: { data: queryData }, + }, + { + request: { query }, + result: { data: queryData }, + }, + ]; + + const refetchQueries = [ + { + query, + }, + ]; + + let count = 0; + const Component = () => ( + + {(createTodo, resultMutation) => ( + + {resultQuery => { + if (count === 0) { + setTimeout(() => { + createTodo(); + }); + } else if (count === 1 && resultMutation) { + expect(resultMutation.loading).toBe(true); + expect(resultQuery.loading).toBe(true); + } else if (count === 2 && resultMutation) { + expect(resultMutation.loading).toBe(true); + expect(stripSymbols(resultQuery.data)).toEqual(queryData); + done(); + } + count++; + return null; + }} + + )} + + ); + + mount( + + + + ); +}); + +it('has an update prop for updating the store after the mutation', done => { + const update = (_proxy: DataProxy, response: ExecutionResult) => { + expect(response.data).toEqual(data); + }; + + let count = 0; + const Component = () => ( + + {createTodo => { + if (count === 0) { + setTimeout(() => { + createTodo().then(response => { + expect(response!.data).toEqual(data); + done(); + }); + }); + } + count++; + return null; + }} + + ); + + mount( + + + + ); +}); + +it('updates if the client changes', done => { + const link1 = mockSingleLink({ + request: { query: mutation }, + result: { data }, + }); + const client1 = new ApolloClient({ + link: link1, + cache: new Cache({ addTypename: false }), + }); + + const data3 = { + createTodo: { + __typename: 'Todo', + id: '100', + text: 'After updating client.', + completed: false, + }, + __typename: 'Mutation', + }; + + const link2 = mockSingleLink({ + request: { query: mutation }, + result: { data: data3 }, + }); + + const client2 = new ApolloClient({ + link: link2, + cache: new Cache({ addTypename: false }), + }); + + let count = 0; + class Component extends React.Component { + state = { + client: client1, + }; + + render() { + return ( + + + {(createTodo, result) => { + if (count === 0) { + expect(result).toBeUndefined(); + setTimeout(() => { + createTodo(); + }); + } else if (count === 2 && result) { + expect(result.data).toEqual(data); + setTimeout(() => { + this.setState({ + client: client2, + }); + }); + } else if (count === 3) { + expect(result).toBeUndefined(); + setTimeout(() => { + createTodo(); + }); + } else if (count === 5 && result) { + expect(result.data).toEqual(data3); + done(); + } + count++; + return null; + }} + + + ); + } + } + + mount(); +}); + +it('errors if a query is passed instead of a mutation', () => { + const query = gql` + query todos { + todos { + id + } + } + `; + + // Prevent error from being logged in console of test. + const errorLogger = console.error; + console.error = () => {}; // tslint:disable-line + + expect(() => { + mount( + + {() => null} + + ); + }).toThrowError( + 'The component requires a graphql mutation, but got a query.' + ); + + console.log = errorLogger; +}); + +it('errors when changing from mutation to a query', done => { + const query = gql` + query todos { + todos { + id + } + } + `; + + class Component extends React.Component { + state = { + query: mutation, + }; + + componentDidCatch(e: Error) { + expect(e).toEqual( + new Error( + 'The component requires a graphql mutation, but got a query.' + ) + ); + done(); + } + render() { + return ( + + {() => { + setTimeout(() => { + this.setState({ + query, + }); + }); + return null; + }} + + ); + } + } + + // Prevent error from being logged in console of test. + const errorLogger = console.error; + console.error = () => {}; // tslint:disable-line + + mount( + + + + ); + + console.log = errorLogger; +}); + +it('errors if a subscription is passed instead of a mutation', () => { + const subscription = gql` + subscription todos { + todos { + id + } + } + `; + + // Prevent error from being logged in console of test. + const errorLogger = console.error; + console.error = () => {}; // tslint:disable-line + + expect(() => { + mount( + + {() => null} + + ); + }).toThrowError( + 'The component requires a graphql mutation, but got a subscription.' + ); + + console.log = errorLogger; +}); + +it('errors when changing from mutation to a subscription', done => { + const subscription = gql` + subscription todos { + todos { + id + } + } + `; + + class Component extends React.Component { + state = { + query: mutation, + }; + + componentDidCatch(e: Error) { + expect(e).toEqual( + new Error( + 'The component requires a graphql mutation, but got a subscription.' + ) + ); + done(); + } + render() { + return ( + + {() => { + setTimeout(() => { + this.setState({ + query: subscription, + }); + }); + return null; + }} + + ); + } + } + + // Prevent error from being logged in console of test. + const errorLogger = console.error; + console.error = () => {}; // tslint:disable-line + + mount( + + + + ); + + console.log = errorLogger; +});