├── .babelrc.js ├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── package.json ├── renovate.json ├── src └── index.tsx ├── test ├── .babelrc ├── __snapshots__ │ └── index.test.tsx.snap ├── fixtures │ ├── schema.graphql │ ├── schema.ts │ └── updateSchema.ts ├── globals.d.ts ├── helpers.ts ├── index.test.tsx └── tsconfig.json ├── tsconfig.json └── yarn.lock /.babelrc.js: -------------------------------------------------------------------------------- 1 | module.exports = api => ({ 2 | presets: [ 3 | [ 4 | '@4c', 5 | { 6 | target: 'web', 7 | targets: {}, 8 | modules: api.env() === 'esm' ? false : 'commonjs', 9 | }, 10 | ], 11 | '@babel/typescript', 12 | ], 13 | }); 14 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | **/__generated__/ 2 | **/coverage/** 3 | **/es/** 4 | **/lib/** 5 | **/fixtures/** 6 | **/flow-typed/** 7 | **/node_modules/** 8 | **/CHANGELOG.md 9 | **/package.json 10 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "4catalyzer-react", 4 | "4catalyzer-typescript", 5 | "4catalyzer-jest", 6 | "prettier", 7 | "prettier/react", 8 | "prettier/@typescript-eslint" 9 | ], 10 | "plugins": ["prettier"], 11 | "env": { 12 | "browser": true 13 | }, 14 | "rules": { 15 | "prettier/prettier": "error" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib/ 2 | es/ 3 | 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | 11 | # Runtime data 12 | pids 13 | *.pid 14 | *.seed 15 | *.pid.lock 16 | 17 | # Directory for instrumented libs generated by jscoverage/JSCover 18 | lib-cov 19 | 20 | # Coverage directory used by tools like istanbul 21 | coverage 22 | 23 | # nyc test coverage 24 | .nyc_output 25 | 26 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 27 | .grunt 28 | 29 | # Bower dependency directory (https://bower.io/) 30 | bower_components 31 | 32 | # node-waf configuration 33 | .lock-wscript 34 | 35 | # Compiled binary addons (http://nodejs.org/api/addons.html) 36 | build/Release 37 | 38 | # Dependency directories 39 | node_modules/ 40 | jspm_packages/ 41 | 42 | # Typescript v1 declaration files 43 | typings/ 44 | 45 | # Optional npm cache directory 46 | .npm 47 | 48 | # Optional eslint cache 49 | .eslintcache 50 | 51 | # Optional REPL history 52 | .node_repl_history 53 | 54 | # Output of 'npm pack' 55 | *.tgz 56 | 57 | # Yarn Integrity file 58 | .yarn-integrity 59 | 60 | # dotenv environment variables file 61 | .env 62 | 63 | # Generated files. 64 | __generated__ 65 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - node 4 | 5 | cache: 6 | yarn: true 7 | npm: true 8 | 9 | after_script: 10 | - node_modules/.bin/codecov 11 | 12 | branches: 13 | only: 14 | - master 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 4Catalyzer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Relay Mutation [![Travis][build-badge]][build] [![npm][npm-badge]][npm] 2 | 3 | Higher-level [React](https://reactjs.org/) mutation API for [Relay](https://relay.dev/). 4 | 5 | ## Usage 6 | 7 | This package provides a `useMutation` Hook and a `` component. These wrap up committing Relay mutations and keeping track of the mutation state. 8 | 9 | ```js 10 | import React from 'react'; 11 | import { Mutation, useMutation } from 'react-relay-mutation'; 12 | 13 | /* ... */ 14 | 15 | function MyComponentWithHook({ myValue }) { 16 | const [mutate, { loading }] = useMutation( 17 | graphql` 18 | mutation ExampleWithHookMutation($input: MyMutationInput) { 19 | myMutation(input: $input) { 20 | value 21 | } 22 | } 23 | `, 24 | { 25 | onCompleted: ({ myMutation }) => { 26 | window.alert(`received ${myMutation.value}`); 27 | }, 28 | }, 29 | ); 30 | 31 | return loading ? ( 32 | 33 | ) : ( 34 | 45 | ); 46 | } 47 | 48 | function MyComponentWithComponent({ myValue }) { 49 | return ( 50 | { 59 | window.alert(`received ${myMutation.value}`); 60 | }} 61 | > 62 | {([mutate, { loading }]) => 63 | loading ? ( 64 | 65 | ) : ( 66 | 77 | ) 78 | } 79 | 80 | ); 81 | } 82 | ``` 83 | 84 | The `useMutation` hook and the `` component take a mutation node and optionally any mutation options valid for `commitMutation` in Relay, except that `onCompleted` only takes a single argument for the response, as errors there will be handled identically to request errors. The `useMutation` hook takes the mutation as its first argument, and the optional configuration object as its second argument. The `` component takes the mutation node as the `mutation` prop, and any other options as props by name. In both cases, `variables` is optional. 85 | 86 | Both `useMutation` and `` provide a tuple of a `mutate` callback and a `mutationState` object. This is the return value for `useMutation` and the argument passed into the function child for ``. 87 | 88 | The `mutate` callback optionally takes a configuration object as above. Any options specified here will override the options specified to `useMutation` or to ``. Additionally, if `variables` was not specified above, it must be specified here. The `mutate` callback returns a promise. This will resolve with the mutation response or reject with any error (except when an `onError` callback is specified, in which case it will resolve with no value on errors). 89 | 90 | The `mutationState` object has the following properties: 91 | 92 | - `loading`: a boolean indicating whether the mutation is currently pending 93 | - `data`: the response data for the mutation 94 | - `error`: any errors returned by the mutation 95 | 96 | ### Specifying the Relay environment 97 | 98 | By default, `useMutation` and `` take the Relay environment from React context. This context is automatically provided by `` and by many integrations that replace ``, such as Found Relay. 99 | 100 | If you do not already have the Relay environment in context, you can provide the environment manually: 101 | 102 | ```js 103 | import { ReactRelayContext } from 'react-relay'; 104 | 105 | /* ... */ 106 | 107 | function MyAppWithRelayEnvironment() { 108 | return ( 109 | 115 | 116 | 117 | ); 118 | } 119 | ``` 120 | 121 | You can also pass in the environment in the configuration object: 122 | 123 | ```js 124 | const [mutate] = useMutation(mutation, { environment }); 125 | ``` 126 | 127 | ## Acknowledgements 128 | 129 | This library closely follows the mutation API in [React Apollo](https://www.apollographql.com/docs/react/). 130 | 131 | [build-badge]: https://img.shields.io/travis/relay-tools/react-relay-mutation/master.svg 132 | [build]: https://travis-ci.org/relay-tools/react-relay-mutation 133 | [npm-badge]: https://img.shields.io/npm/v/react-relay-mutation.svg 134 | [npm]: https://www.npmjs.org/package/react-relay-mutation 135 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-relay-mutation", 3 | "version": "0.2.2", 4 | "description": "Higher-level React mutation API for Relay", 5 | "license": "MIT", 6 | "author": "4Catalyzer", 7 | "main": "lib/index.js", 8 | "module": "es/index.js", 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/relay-tools/react-relay-mutation.git" 12 | }, 13 | "scripts": { 14 | "babel": "babel src --ignore **/__tests__ --delete-dir-on-start -x .js,.ts,.tsx", 15 | "build": "yarn babel -d lib && yarn babel -d es --env-name esm && yarn build:types", 16 | "build:fixtures": "npm run update-schema && npm run relay-compiler", 17 | "build:types": "tsc --emitDeclarationOnly --outDir lib && tsc --outDir es", 18 | "eslint": "eslint . --ext .js,.ts,.tsx", 19 | "format": "yarn eslint --fix && yarn prettier --write", 20 | "lint": "yarn eslint && yarn prettier --list-different", 21 | "prepublishOnly": "yarn run build", 22 | "prettier": "prettier --ignore-path .eslintignore '**/*.{json,css,md}'", 23 | "relay-compiler": "relay-compiler --watchman false --src test --schema test/fixtures/schema.graphql --language typescript --extensions js ts tsx", 24 | "release": "rollout", 25 | "tdd": "jest --watch", 26 | "test": "yarn build:fixtures && yarn lint && yarn typecheck && yarn testonly --coverage", 27 | "testonly": "jest", 28 | "typecheck": "tsc --noEmit && tsc --noEmit -p test", 29 | "update-schema": "ts-node --skip-project test/fixtures/updateSchema.ts" 30 | }, 31 | "husky": { 32 | "hooks": { 33 | "pre-commit": "lint-staged" 34 | } 35 | }, 36 | "lint-staged": { 37 | "*.{js,ts,tsx}": "eslint --fix", 38 | "*.{json,css,md}": "prettier --write --ignore-path .eslintignore" 39 | }, 40 | "prettier": { 41 | "printWidth": 79, 42 | "singleQuote": true, 43 | "trailingComma": "all" 44 | }, 45 | "jest": { 46 | "preset": "@4c/jest-preset" 47 | }, 48 | "dependencies": { 49 | "@types/react": ">=16.8.0", 50 | "@types/react-relay": ">=4.0.0", 51 | "@types/relay-runtime": ">=4.0.0" 52 | }, 53 | "devDependencies": { 54 | "@4c/babel-preset": "^8.0.1", 55 | "@4c/jest-preset": "^1.5.3", 56 | "@4c/rollout": "^2.1.11", 57 | "@4c/tsconfig": "^0.3.1", 58 | "@babel/cli": "^7.12.1", 59 | "@babel/core": "^7.12.3", 60 | "@babel/preset-typescript": "^7.12.1", 61 | "@types/enzyme": "^3.10.8", 62 | "@types/graphql-relay": "^0.6.0", 63 | "@types/invariant": "^2.2.34", 64 | "@types/jest": "^26.0.15", 65 | "@types/node": "^14.14.7", 66 | "@types/react-dom": "^16.9.9", 67 | "@typescript-eslint/eslint-plugin": "^4.7.0", 68 | "@typescript-eslint/parser": "^4.7.0", 69 | "babel-jest": "^26.6.3", 70 | "babel-plugin-relay": "^10.0.1", 71 | "codecov": "^3.8.1", 72 | "enzyme": "^3.11.0", 73 | "enzyme-adapter-react-16": "^1.15.5", 74 | "eslint": "^7.13.0", 75 | "eslint-config-4catalyzer-jest": "^2.0.9", 76 | "eslint-config-4catalyzer-react": "^1.0.12", 77 | "eslint-config-4catalyzer-typescript": "^3.0.1", 78 | "eslint-config-prettier": "^7.0.0", 79 | "eslint-plugin-import": "^2.22.1", 80 | "eslint-plugin-jest": "^24.1.3", 81 | "eslint-plugin-jsx-a11y": "^6.4.1", 82 | "eslint-plugin-prettier": "^3.1.4", 83 | "eslint-plugin-react": "^7.21.5", 84 | "eslint-plugin-react-hooks": "^4.2.0", 85 | "graphql": "^15.4.0", 86 | "graphql-relay": "^0.6.0", 87 | "husky": "^4.3.0", 88 | "jest": "^26.6.3", 89 | "lint-staged": "^10.5.1", 90 | "prettier": "^2.1.2", 91 | "react": "^16.14.0", 92 | "react-dom": "^16.14.0", 93 | "react-relay": "^10.0.1", 94 | "relay-compiler": "^10.0.1", 95 | "relay-compiler-language-typescript": "^13.0.2", 96 | "relay-local-schema": "^0.8.0", 97 | "relay-runtime": "^10.0.1", 98 | "ts-node": "^9.0.0", 99 | "typescript": "^4.0.5" 100 | }, 101 | "peerDependencies": { 102 | "react": ">=16.8.0", 103 | "react-relay": ">=2.0.0", 104 | "relay-runtime": ">=2.0.0" 105 | }, 106 | "publishConfig": { 107 | "access": "public" 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["github>4Catalyzer/renovate-config:library", ":automergeMinor"] 3 | } 4 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | useCallback, 3 | useContext, 4 | useEffect, 5 | useRef, 6 | useState, 7 | } from 'react'; 8 | import { ReactRelayContext, commitMutation } from 'react-relay'; 9 | import { 10 | MutationConfig as BaseMutationConfig, 11 | Environment, 12 | MutationParameters, 13 | } from 'relay-runtime'; 14 | 15 | export type MutationState = { 16 | loading: boolean; 17 | data: T['response'] | null; 18 | error: Error | null; 19 | }; 20 | 21 | export type MutationNode = BaseMutationConfig< 22 | T 23 | >['mutation']; 24 | 25 | export type MutationConfig = Partial< 26 | Omit, 'mutation' | 'onCompleted'> 27 | > & { 28 | onCompleted?(response: T['response']): void; 29 | }; 30 | 31 | export type Mutate = ( 32 | config?: Partial>, 33 | ) => Promise; 34 | 35 | export function useMutation( 36 | mutation: MutationNode, 37 | userConfig: MutationConfig = {}, 38 | /** if not provided, the context environment will be used. */ 39 | environment?: Environment, 40 | ): [Mutate, MutationState] { 41 | const [state, setState] = useState>({ 42 | loading: false, 43 | data: null, 44 | error: null, 45 | }); 46 | 47 | const mounted = useRef(true); 48 | 49 | useEffect( 50 | () => () => { 51 | mounted.current = false; 52 | }, 53 | [], 54 | ); 55 | 56 | const relayContext = useContext(ReactRelayContext); 57 | const resolvedEnvironment = environment || relayContext!.environment; 58 | const { 59 | configs, 60 | variables, 61 | uploadables, 62 | onCompleted, 63 | onError, 64 | optimisticUpdater, 65 | optimisticResponse, 66 | updater, 67 | } = userConfig; 68 | 69 | const mutate: Mutate = useCallback( 70 | (config) => { 71 | const mergedConfig = { 72 | configs, 73 | variables, 74 | uploadables, 75 | onCompleted, 76 | onError, 77 | optimisticUpdater, 78 | optimisticResponse, 79 | updater, 80 | ...config, 81 | }; 82 | 83 | if (!mergedConfig.variables) { 84 | throw Error('you must specify variables'); 85 | } 86 | 87 | setState({ 88 | loading: true, 89 | data: null, 90 | error: null, 91 | }); 92 | 93 | return new Promise((resolve, reject) => { 94 | function handleError(error: any) { 95 | if (mounted.current) { 96 | setState({ 97 | loading: false, 98 | data: null, 99 | error, 100 | }); 101 | } 102 | 103 | if (mergedConfig.onError) { 104 | mergedConfig.onError(error); 105 | resolve(); 106 | } else { 107 | reject(error); 108 | } 109 | } 110 | 111 | commitMutation(resolvedEnvironment, { 112 | ...mergedConfig, 113 | mutation, 114 | variables: mergedConfig.variables!, 115 | onCompleted: (response, errors) => { 116 | if (errors) { 117 | // FIXME: This isn't right. onError expects a single error. 118 | handleError(errors); 119 | return; 120 | } 121 | 122 | if (mounted.current) { 123 | setState({ 124 | loading: false, 125 | data: response, 126 | error: null, 127 | }); 128 | } 129 | 130 | if (mergedConfig.onCompleted) { 131 | mergedConfig.onCompleted(response); 132 | } 133 | resolve(response); 134 | }, 135 | onError: handleError, 136 | }); 137 | }); 138 | }, 139 | [ 140 | resolvedEnvironment, 141 | configs, 142 | mutation, 143 | variables, 144 | uploadables, 145 | onCompleted, 146 | onError, 147 | optimisticUpdater, 148 | optimisticResponse, 149 | updater, 150 | ], 151 | ); 152 | 153 | return [mutate, state]; 154 | } 155 | 156 | export type MutationProps = MutationConfig & { 157 | children: (mutate: Mutate, state: MutationState) => React.ReactNode; 158 | mutation: MutationNode; 159 | /** if not provided, the context environment will be used. */ 160 | environment?: Environment; 161 | }; 162 | 163 | export function Mutation({ 164 | children, 165 | mutation, 166 | environment, 167 | ...config 168 | }: MutationProps) { 169 | const [mutate, state] = useMutation(mutation, config, environment); 170 | return children(mutate, state) as React.ReactElement; 171 | } 172 | -------------------------------------------------------------------------------- /test/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@4c", { 4 | "target": "node" 5 | }], 6 | "@babel/typescript" 7 | ], 8 | "plugins": [ 9 | "relay" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /test/__snapshots__/index.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`hook should fail with promises 1`] = ` 4 | [Error: Variable "$input" of non-null type "DoStuffInput!" must not be null. 5 | 6 | GraphQL request:2:3 7 | 1 | mutation testMutation( 8 | 2 | $input: DoStuffInput! 9 | | ^ 10 | 3 | ) {] 11 | `; 12 | 13 | exports[`hook should resolve promise on error when onError is set 1`] = `undefined`; 14 | 15 | exports[`hook should succeed with promises 1`] = ` 16 | Object { 17 | "doStuff": Object { 18 | "result": "you did it Mr. Bar", 19 | }, 20 | } 21 | `; 22 | 23 | exports[`hook should work: onCompleted 1`] = ` 24 | Array [ 25 | Array [ 26 | Object { 27 | "doStuff": Object { 28 | "result": "you did it Mr. Bar", 29 | }, 30 | }, 31 | ], 32 | ] 33 | `; 34 | 35 | exports[`hook should work: onError 1`] = `Array []`; 36 | 37 | exports[`hook should work: state 1`] = ` 38 | Array [ 39 | Object { 40 | "data": null, 41 | "error": null, 42 | "loading": false, 43 | }, 44 | Object { 45 | "data": null, 46 | "error": null, 47 | "loading": true, 48 | }, 49 | Object { 50 | "data": Object { 51 | "doStuff": Object { 52 | "result": "you did it Mr. Bar", 53 | }, 54 | }, 55 | "error": null, 56 | "loading": false, 57 | }, 58 | ] 59 | `; 60 | 61 | exports[`render prop should fire onCompleted: onCompleted 1`] = ` 62 | Array [ 63 | Array [ 64 | Object { 65 | "doStuff": Object { 66 | "result": "you did it Mr. Foo", 67 | }, 68 | }, 69 | ], 70 | ] 71 | `; 72 | 73 | exports[`render prop should fire onCompleted: onError 1`] = `Array []`; 74 | 75 | exports[`render prop should fire onCompleted: state 1`] = ` 76 | Array [ 77 | Object { 78 | "data": null, 79 | "error": null, 80 | "loading": false, 81 | }, 82 | Object { 83 | "data": null, 84 | "error": null, 85 | "loading": true, 86 | }, 87 | Object { 88 | "data": Object { 89 | "doStuff": Object { 90 | "result": "you did it Mr. Foo", 91 | }, 92 | }, 93 | "error": null, 94 | "loading": false, 95 | }, 96 | ] 97 | `; 98 | 99 | exports[`render prop should fire onError: onCompleted 1`] = `Array []`; 100 | 101 | exports[`render prop should fire onError: onError 1`] = ` 102 | Array [ 103 | Array [ 104 | [Error: Variable "$input" of non-null type "DoStuffInput!" must not be null. 105 | 106 | GraphQL request:2:3 107 | 1 | mutation testMutation( 108 | 2 | $input: DoStuffInput! 109 | | ^ 110 | 3 | ) {], 111 | ], 112 | ] 113 | `; 114 | 115 | exports[`render prop should fire onError: state 1`] = ` 116 | Array [ 117 | Object { 118 | "data": null, 119 | "error": null, 120 | "loading": false, 121 | }, 122 | Object { 123 | "data": null, 124 | "error": null, 125 | "loading": true, 126 | }, 127 | Object { 128 | "data": null, 129 | "error": [Error: Variable "$input" of non-null type "DoStuffInput!" must not be null. 130 | 131 | GraphQL request:2:3 132 | 1 | mutation testMutation( 133 | 2 | $input: DoStuffInput! 134 | | ^ 135 | 3 | ) {], 136 | "loading": false, 137 | }, 138 | ] 139 | `; 140 | -------------------------------------------------------------------------------- /test/fixtures/schema.graphql: -------------------------------------------------------------------------------- 1 | type Query { 2 | dummy: ID 3 | } 4 | 5 | type Mutation { 6 | doStuff(input: DoStuffInput!): DoStuffPayload 7 | } 8 | 9 | type DoStuffPayload { 10 | result: String 11 | clientMutationId: String 12 | } 13 | 14 | input DoStuffInput { 15 | name: String! 16 | clientMutationId: String 17 | } 18 | -------------------------------------------------------------------------------- /test/fixtures/schema.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GraphQLID, 3 | GraphQLNonNull, 4 | GraphQLObjectType, 5 | GraphQLSchema, 6 | GraphQLString, 7 | } from 'graphql'; 8 | import { mutationWithClientMutationId } from 'graphql-relay'; 9 | 10 | const doStuff = mutationWithClientMutationId({ 11 | name: 'DoStuff', 12 | inputFields: { 13 | name: { type: GraphQLNonNull(GraphQLString) }, 14 | }, 15 | outputFields: { 16 | result: { type: GraphQLString }, 17 | }, 18 | 19 | mutateAndGetPayload: ({ name }) => { 20 | return { 21 | result: `you did it ${name}` 22 | } 23 | }, 24 | }); 25 | 26 | const mutation = new GraphQLObjectType({ 27 | name: 'Mutation', 28 | fields: { 29 | doStuff 30 | }, 31 | }); 32 | 33 | const query = new GraphQLObjectType({ 34 | name: 'Query', 35 | fields: { 36 | // XXX: relay-compiler chokes unless at least one type has an ID. 37 | dummy: { type: GraphQLID } 38 | }, 39 | }); 40 | 41 | export default new GraphQLSchema({ query: query, mutation }); 42 | -------------------------------------------------------------------------------- /test/fixtures/updateSchema.ts: -------------------------------------------------------------------------------- 1 | import { writeFileSync } from 'fs'; 2 | import { join } from 'path'; 3 | import { printSchema } from 'graphql/utilities'; 4 | 5 | import schema from './schema'; 6 | 7 | writeFileSync(join(__dirname, 'schema.graphql'), printSchema(schema)); 8 | -------------------------------------------------------------------------------- /test/globals.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'relay-local-schema'; 2 | declare module 'enzyme-adapter-react-16'; 3 | -------------------------------------------------------------------------------- /test/helpers.ts: -------------------------------------------------------------------------------- 1 | import { createFetch } from 'relay-local-schema'; 2 | import { Environment, Network, RecordSource, Store } from 'relay-runtime'; 3 | 4 | import schema from './fixtures/schema'; 5 | 6 | export function createFakeFetch() { 7 | return createFetch({ schema }); 8 | } 9 | 10 | export function timeout(delay: number) { 11 | return new Promise((resolve) => { 12 | setTimeout(resolve, delay); 13 | }); 14 | } 15 | 16 | export function createSyncEnvironment( 17 | fetch = createFakeFetch(), 18 | records: any, 19 | ) { 20 | return new Environment({ 21 | network: Network.create(fetch), 22 | store: new Store(new RecordSource(records)), 23 | }); 24 | } 25 | 26 | export function createEnvironment(fetch = createFakeFetch(), records?: any) { 27 | return createSyncEnvironment(async (...args: any) => { 28 | // // Delay field resolution to exercise async data fetching logic. 29 | await timeout(5); 30 | return fetch(...args); 31 | }, records); 32 | } 33 | -------------------------------------------------------------------------------- /test/index.test.tsx: -------------------------------------------------------------------------------- 1 | import Enzyme, { shallow } from 'enzyme'; 2 | import Adapter from 'enzyme-adapter-react-16'; 3 | import React from 'react'; 4 | import { graphql } from 'react-relay'; 5 | import { Environment } from 'relay-runtime'; 6 | 7 | import { Mutation, useMutation } from '../src'; 8 | import { testMutation } from './__generated__/testMutation.graphql'; 9 | import { createEnvironment, timeout } from './helpers'; 10 | 11 | Enzyme.configure({ adapter: new Adapter() }); 12 | 13 | let environment: Environment; 14 | 15 | beforeEach(() => { 16 | environment = createEnvironment(); 17 | }); 18 | 19 | const mutation = graphql` 20 | mutation testMutation($input: DoStuffInput!) { 21 | doStuff(input: $input) { 22 | result 23 | } 24 | } 25 | `; 26 | 27 | describe('render prop', () => { 28 | it('should fire onCompleted', async () => { 29 | const renderedStates: any[] = []; 30 | const onCompleted = jest.fn(); 31 | const onError = jest.fn(); 32 | 33 | const element = shallow( 34 | 35 | mutation={mutation} 36 | environment={environment} 37 | variables={{ input: { name: 'Mr. Foo' } }} 38 | onCompleted={onCompleted} 39 | onError={onError} 40 | > 41 | {(mutate, mutationState) => { 42 | renderedStates.push(mutationState); 43 | return ( 44 | 47 | ); 48 | }} 49 | , 50 | ); 51 | 52 | element.find('button').simulate('click'); 53 | 54 | await timeout(10); 55 | 56 | expect(onCompleted.mock.calls).toMatchSnapshot('onCompleted'); 57 | expect(onError.mock.calls).toMatchSnapshot('onError'); 58 | expect(renderedStates).toMatchSnapshot('state'); 59 | }); 60 | 61 | it('should fire onError', async () => { 62 | const onCompleted = jest.fn(); 63 | const onError = jest.fn(); 64 | const renderedStates: any[] = []; 65 | 66 | const element = shallow( 67 | 68 | mutation={mutation} 69 | environment={environment} 70 | variables={{ wrongInput: 1 } as any} 71 | onCompleted={onCompleted} 72 | onError={onError} 73 | > 74 | {(mutate, mutationState) => { 75 | renderedStates.push(mutationState); 76 | return ( 77 | 80 | ); 81 | }} 82 | , 83 | ); 84 | 85 | element.find('button').simulate('click'); 86 | 87 | await timeout(10); 88 | 89 | expect(onCompleted.mock.calls).toMatchSnapshot('onCompleted'); 90 | expect(onError.mock.calls).toMatchSnapshot('onError'); 91 | expect(renderedStates).toMatchSnapshot('state'); 92 | }); 93 | }); 94 | 95 | describe('hook', () => { 96 | it('should work', async () => { 97 | const onCompleted = jest.fn(); 98 | const onError = jest.fn(); 99 | const renderedStates: any[] = []; 100 | 101 | const Component = () => { 102 | const [mutate, mutationState] = useMutation( 103 | mutation, 104 | { onCompleted, onError }, 105 | environment, 106 | ); 107 | renderedStates.push(mutationState); 108 | 109 | return ( 110 | 120 | ); 121 | }; 122 | 123 | const element = shallow(); 124 | 125 | element.find('button').simulate('click'); 126 | 127 | await timeout(10); 128 | 129 | expect(onCompleted.mock.calls).toMatchSnapshot('onCompleted'); 130 | expect(onError.mock.calls).toMatchSnapshot('onError'); 131 | expect(renderedStates).toMatchSnapshot('state'); 132 | }); 133 | 134 | it('should succeed with promises', async () => { 135 | let called = false; 136 | 137 | const Component = () => { 138 | const [mutate] = useMutation(mutation, {}, environment); 139 | 140 | return ( 141 | 153 | ); 154 | }; 155 | 156 | const element = shallow(); 157 | 158 | element.find('button').simulate('click'); 159 | 160 | await timeout(10); 161 | 162 | expect(called).toBe(true); 163 | }); 164 | 165 | it('should fail with promises', async () => { 166 | let called = false; 167 | 168 | const Component = () => { 169 | const [mutate] = useMutation(mutation, {}, environment); 170 | 171 | return ( 172 | 187 | ); 188 | }; 189 | 190 | const element = shallow(); 191 | 192 | element.find('button').simulate('click'); 193 | 194 | await timeout(10); 195 | 196 | expect(called).toBe(true); 197 | }); 198 | 199 | it('should resolve promise on error when onError is set', async () => { 200 | let called = false; 201 | 202 | const Component = () => { 203 | const [mutate] = useMutation( 204 | mutation, 205 | { 206 | onError: jest.fn(), 207 | }, 208 | environment, 209 | ); 210 | 211 | return ( 212 | 224 | ); 225 | }; 226 | 227 | const element = shallow(); 228 | 229 | element.find('button').simulate('click'); 230 | 231 | await timeout(10); 232 | 233 | expect(called).toBe(true); 234 | }); 235 | }); 236 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@4c/tsconfig/web", 3 | "compilerOptions": { 4 | "types": ["jest", "node"] 5 | }, 6 | "include": [".", "../src"] 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@4c/tsconfig/web", 3 | "compilerOptions": { 4 | "rootDir": "src" 5 | }, 6 | "include": ["src/**/*.ts", "src/**/*.tsx"] 7 | } 8 | --------------------------------------------------------------------------------