Skip to content

Instantly share code, notes, and snippets.

@RabidFire
Last active September 10, 2018 07:34
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save RabidFire/30ce6047a628f2b6a1e20c7db1136315 to your computer and use it in GitHub Desktop.
Save RabidFire/30ce6047a628f2b6a1e20c7db1136315 to your computer and use it in GitHub Desktop.
React + Apollo Tricks & Boilerplate
import { InMemoryCache } from 'apollo-cache-inmemory'
const cache = new InMemoryCache()
export default cache
import ApolloClient from 'apollo-client'
import { from } from 'apollo-link'
import authLink from './authLink'
import cache from './cache'
import errorLink from './errorLink'
import httpLink from './httpLink'
import stateLink from './stateLink'
const client = new ApolloClient({
link: from([
authLink,
errorLink,
stateLink,
httpLink
]),
cache
})
/*
https://www.apollographql.com/docs/link/links/state.html#defaults
https://www.apollographql.com/docs/react/advanced/caching.html#reset-store
The cache is not reset back to defaults set in `stateLink` on client.resetStore().
Therefore, we need to rehydrate cache with defaults using `onResetStore()`.
*/
client.onResetStore(stateLink.writeDefaults)
export default client
import client from 'client'
import { ALERT_FAILURE, ALERT_SUCCESS } from 'mutations/alert'
const logout = () => { client.resetStore() }
const showAlertFailure = alert => client.mutate({ mutation: ALERT_FAILURE, variables: { alert } })
const showAlertSuccess = alert => client.mutate({ mutation: ALERT_SUCCESS, variables: { alert } })
export { logout, showAlertFailure, showAlertSuccess }
import gql from 'graphql-tag'
import React from 'react'
import LoginForm from 'components/forms/LoginForm'
import { withClientMutation, withMutation } from 'lib/data'
import SET_TOKEN from 'mutations/session'
function LoginPage({ createSession, setToken }) {
const handleSubmit = (values, formApi, callback) => (
createSession(values, formApi, callback)
.then(response => setToken({ token: response.data.session.token }))
)
return (
<LoginForm onSubmit={handleSubmit} />
)
}
LoginPage = withMutation(gql`
mutation CreateSessionMutation($input: CreateSessionInput!) {
session: createSession(input: $input) {
id
token
}
}
`)(LoginPage)
LoginPage = withClientMutation(SET_TOKEN)(LoginPage)
export default LoginPage
import { compose, graphql } from 'react-apollo'
import { filter } from 'graphql-anywhere'
import parseError from 'lib/errorParser'
import { showAlertFailure, showAlertSuccess } from 'client/methods'
const OptimisticResponseModes = Object.freeze({
CREATE: 'CREATE',
UPDATE: 'UPDATE',
DESTROY: 'DESTROY'
})
const MutationResponseModes = Object.freeze({
IGNORE: 'IGNORE',
APPEND: 'APPEND',
PREPEND: 'PREPEND',
DELETE: 'DELETE'
})
// Helpers
const findMutationMethodName = mutation => (
mutation.definitions[0].selectionSet.selections[0].name.value
)
const findMutationMethodNameOrAlias = (mutation) => {
const selection = mutation.definitions[0].selectionSet.selections[0]
return (selection.alias && selection.alias.value) || selection.name.value
}
const findQueryFieldNameOrAlias = (query) => {
const selection = query.definitions[0].selectionSet.selections[0]
return (selection.alias && selection.alias.value) || selection.name.value
}
const addRecords = (
currentRecords = [],
responseRecords = [],
mode = MutationResponseModes.APPEND
) => {
switch (mode) {
case MutationResponseModes.APPEND:
return currentRecords.push(...responseRecords)
case MutationResponseModes.PREPEND:
return currentRecords.unshift(...responseRecords)
default:
throw new Error('Incorrect `mode` specified when using addRecords.')
}
}
const deleteRecords = (currentRecords = [], responseRecords = [], key = 'id') => {
const deleteIds = responseRecords.map(record => record[key])
deleteIds.forEach((deleteId) => {
const index = currentRecords.findIndex(record => record[key] === deleteId)
if (index !== -1) {
currentRecords.splice(index, 1)
}
})
}
const optimisticCreateResponse = (other = {}) => ({ input }) => ({
id: Math.round(Math.random() * -1000000),
createdAt: +new Date(),
updatedAt: +new Date(),
...input,
...other
})
const optimisticUpdateResponse = (other = {}) => ({ id, input }) => ({
id,
updatedAt: +new Date(),
...input,
...other
})
const optimisticDestroyResponse = (other = {}) => ({ id }) => ({
id,
...other
})
// React apollo HOCs
const withClientMutation = (mutation) => {
const methodName = findMutationMethodName(mutation)
return graphql(mutation, {
props: ({ mutate }) => ({
[methodName]: variables => mutate({ variables })
})
})
}
const withClientQuery = (query, config = {}) => {
const configOptions = config.options || {}
delete config.options
const configWithDefaults = Object.assign({
props: ({ data }) => ({ ...data }),
options: (props) => {
const defaultOptions = {
fetchPolicy: 'cache-only'
}
if (typeof configOptions === 'function') {
return {
...defaultOptions,
...configOptions(props)
}
}
return {
...defaultOptions,
...configOptions
}
}
}, config)
if (configWithDefaults.name) { // Automatically set by the field.
throw new Error('You cannot override the `name` when using `withClientQuery`.')
}
return graphql(query, configWithDefaults)
}
const withMutation = (mutation, {
query: mutationQuery,
mode = MutationResponseModes.IGNORE,
inputFilter,
refetch = false,
optimistic,
successAlert
} = {}) => {
const methodName = findMutationMethodName(mutation)
return graphql(mutation, {
props: ({ mutate, ownProps: { query: componentQuery, variables } }) => ({
[methodName]: (values, formApi, callback) => {
/*
https://github.com/final-form/final-form/blob/master/src/FinalForm.js#L864
https://www.ecma-international.org/ecma-262/6.0/#sec-function-definitions-static-semantics-expectedargumentcount
[methodName]: ({ id, ...input } = {}, formApi, callback)
Destructuring like the above statement will not work with react-final-form
because final-form uses `function.length` on onSubmit
to determine if `callback` will be called.
And as per the ecma specs, internally, parameters without default that appear after
one with default are ‘considered to be optional with undefined as their default value’.
const fn1 = (a, b) => { }
const fn2 = (a, ...b) => { }
const fn3 = (a, b = 2) => { }
const fn4 = (a = 1, b) => { }
fn1.length // -> 2
fn2.length // -> 1
fn3.length // -> 1
fn4.length // -> 0
*/
const { id, ...rawInput } = values || {}
const input = inputFilter ? filter(inputFilter, rawInput) : rawInput
const mutationConfig = {
variables: { id, input },
errorPolicy: 'none' // Ensure all errors are in catch block
}
const responseName = findMutationMethodNameOrAlias(mutation)
const query = mutationQuery || componentQuery
if (optimistic) {
const { mode: optimisticMode, response: optimisticResponse } = optimistic
if (optimisticResponse) {
const { __typename } = optimisticResponse
let response = null
if (!optimisticMode || !__typename) {
throw new Error('You must specify both `mode` and `__typename` for `optimistic` shorthand.')
}
if (!Object.prototype.hasOwnProperty.call(OptimisticResponseModes, optimisticMode)) {
throw new Error('Incorrect mode specified when using `optimistic` shorthand.')
}
if (optimisticMode === OptimisticResponseModes.CREATE) {
response = optimisticCreateResponse({ __typename })
}
if (optimisticMode === OptimisticResponseModes.UPDATE) {
response = optimisticUpdateResponse({ __typename })
}
if (optimisticMode === OptimisticResponseModes.DESTROY) {
response = optimisticDestroyResponse({ __typename })
}
mutationConfig.optimisticResponse = {
__typename: 'Mutation',
[responseName]: response({ id, input: rawInput })
}
}
}
if (mode !== MutationResponseModes.IGNORE) {
mutationConfig.update = (cache, { data }) => {
const currentRecords = cache.readQuery({ query, variables })
const fieldName = findQueryFieldNameOrAlias(query)
/*
Convert response to array for mutations like BatchCreate*
*/
const responseRecords = (data[responseName].constructor === Array)
? data[responseName] : [ data[responseName] ]
if (mode === MutationResponseModes.APPEND || mode === MutationResponseModes.PREPEND) {
addRecords(currentRecords[fieldName], responseRecords, mode)
} else if (mode === MutationResponseModes.DELETE) {
deleteRecords(currentRecords[fieldName], responseRecords)
}
cache.writeQuery({ data: currentRecords, query, variables })
}
}
if (refetch) {
mutationConfig.refetchQueries = [ { query, variables } ]
}
return mutate({ ...mutationConfig })
.then((response) => {
if (successAlert) {
showAlertSuccess(typeof successAlert === 'function' ? successAlert({ response, input }) : successAlert)
}
/*
This check is for cases like `destroySession`
where no arguments are passed to the mutation method.
Therefore, `formApi` and `callback` will be null.
*/
if (typeof callback === 'function') {
callback() // Call to notify final-form of success
}
return Promise.resolve(response)
}).catch((error) => {
const { alert, submissionError } = parseError(error)
if (submissionError) {
if (typeof callback === 'function') {
callback(submissionError)
}
return Promise.reject(submissionError)
}
if (alert) {
showAlertFailure(alert)
}
if (typeof callback === 'function') {
callback(error)
}
return Promise.reject(error)
})
}
})
})
}
const withQuery = (query, config = {}) => {
const configOptions = config.options || {}
delete config.options
const configWithDefaults = Object.assign({
props: ({ data }) => ({ ...data, query }),
options: (props) => {
const defaultOptions = {
fetchPolicy: 'cache-and-network',
errorPolicy: 'none'
}
if (typeof configOptions === 'function') {
return {
...defaultOptions,
...configOptions(props)
}
}
return {
...defaultOptions,
...configOptions
}
}
}, config)
if (configWithDefaults.name) { // Automatically set by the field.
throw new Error('You cannot override the `name` when using `withQuery`.')
}
return graphql(query, configWithDefaults)
}
/*
This function was created to handle components like `Header.js` wherein we need
to break a query into multiple queries.
Sample use cases -
1. You can skip one of the queries without affecting others within the component.
2. Pass an individual query as argument to withMutation HOC to handle update
*/
const withQueries = queries => compose(queries.map(({ query, config }) => withQuery(query, config)))
export {
MutationResponseModes,
OptimisticResponseModes,
withClientMutation,
withClientQuery,
withMutation,
withQueries,
withQuery
}
import { FORM_ERROR, setIn } from 'final-form'
const errorTypes = Object.freeze({
UNPROCESSABLE_ENTITY: 'UNPROCESSABLE_ENTITY',
UNAUTHORIZED: 'UNAUTHORIZED',
FORBIDDEN: 'FORBIDDEN',
NOT_FOUND: 'NOT_FOUND',
INTERNAL_SERVER_ERROR: 'INTERNAL_SERVER_ERROR'
})
/*
https://www.apollographql.com/docs/link/links/http.html#Errors
https://github.com/apollographql/apollo-link/tree/master/packages/apollo-link-error
*/
const parseSubmissionError = graphQLErrors => (
graphQLErrors.reduce((errors, { type, path, message }) => {
if (type && type === errorTypes.UNPROCESSABLE_ENTITY && path) {
const field = path.join('.')
if (field === 'base') {
return setIn(errors, FORM_ERROR, message)
}
return setIn(errors, field, message)
}
return errors
}, {})
)
const parseError = ({ networkError, graphQLErrors = [] }) => {
let message = null
let submissionError = null
if (networkError) {
const { response, result: { exception, errors } = {} } = networkError
if (!response) {
// Server is down / No internet.
message = 'Please try again after some time.'
} else if (exception) {
/*
Internal Server Errors with status 500.
For eg: `#<ActiveRecord::PendingMigrationError: ...>`
In development, they are not formatted and therefore are not part of the `errors` key.
*/
message = exception
} else if (errors) {
errors.forEach(({ type, path, message: errorMessage }) => {
if (type === errorTypes.UNAUTHORIZED) {
message = errorMessage || 'You seem to have logged out.'
} else if (type === errorTypes.FORBIDDEN) {
message = errorMessage || 'You are not allowed to do that.'
} else if (type === errorTypes.NOT_FOUND) {
message = errorMessage || 'That resource does not exist.'
} else if (type === errorTypes.INTERNAL_SERVER_ERROR) {
message = errorMessage || 'Something went wrong. Our engineers are looking into it.'
} else if (type === errorTypes.UNPROCESSABLE_ENTITY && (!path || path === '')) {
message = errorMessage
}
})
if (!message) {
submissionError = parseSubmissionError(errors)
}
}
} else {
/*
Errors with status 200
For eg: Variable input of type `*Input!` was provided invalid value
*/
graphQLErrors.forEach(({ message: errorMessage }) => {
if (process.env.NODE_ENV === 'development') {
message = errorMessage
} else {
message = "We've been notified. Please try after some time."
}
})
}
const alert = { message }
return { alert, submissionError }
}
export default parseError
import gql from 'graphql-tag'
const ALERT_FAILURE = gql`
mutation OpenFailureAlertMutation($alert: AlertInput!) {
openFailureAlert(alert: $alert) @client
}
`
const ALERT_SUCCESS = gql`
mutation OpenSuccessAlertMutation($alert: AlertInput!) {
openSuccessAlert(alert: $alert) @client
}
`
const CLOSE_ALERT = gql`
mutation CloseAlertMutation {
closeAlert @client
}
`
export { ALERT_FAILURE, ALERT_SUCCESS, CLOSE_ALERT }
import gql from 'graphql-tag'
const SET_TOKEN = gql`
mutation SetTokenMutation($token: String!) {
setToken(token: $token) @client
}
`
export default SET_TOKEN
import gql from 'graphql-tag'
const GET_ALERT = gql`
query AlertQuery {
alert @client {
isOpen
icon
message
title
variant
}
}
`
export default GET_ALERT
import gql from 'graphql-tag'
const GET_TOKEN = gql`
query SessionQuery {
session @client {
token
}
}
`
export default GET_TOKEN
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment