modules-pack
Version:
JavaScript Modules for Modern Frontend & Backend Projects
502 lines (466 loc) • 20.2 kB
JavaScript
import { useMutation, useQuery } from '@apollo/client'
import { idFromRoute, uriFrom } from 'modules-pack/router/utils'
import { ROUTES } from 'modules-pack/variables'
import React from 'react'
import { getOriginalClass } from 'react-ui-pack'
import LoadingView from 'react-ui-pack/Loading'
import {
Active,
get,
isEmpty,
isEqual,
isFunction,
isList,
sanitizeResponse,
set,
toList,
toLowerCase
} from 'utils-pack'
import { GQL_HIDDEN_FIELDS } from './constants'
/**
* GRAPHQL REQUEST HELPERS =====================================================
* @Architecture
* - see https://www.apollographql.com/blog/apollo-client/performance/batching-client-graphql-queries/
* - Batching Queries is better than merge, but has downsides:
* + Pros: single HTTP request
* + Pros: performance tracking
* - Cons: no whole-query cache TTLs
* - Cons: blocking UI with the slowest query
* - Optimistic chained queries:
* + Pros: performance tracking
* + Pros: separate cache policies
* + Pros: non-blocking progressive UI
* - Cons: multiple HTTP requests
* => Use chained queries with optional `optimistic` flag to fire multiple requests simultaneously.
* =============================================================================
*/
/** `gqlRequestDecorator()` config flag to set defaults for requests without variables that always need querying */
export const reusable = true
/** whether to render the component immediately (good for non-blocking chained requests) */
export const optimistic = true
/**
* `gqlRequestDecorator()` configs for using the request response as dropdown options
* @example:
* gqlRequestDecorator({
* propsMapperOptions: {asProp: 'tagOptions', ...asDropdownOptions},
* })
*/
export const asDropdownOptions = {
mapEntry: (list) => list.map(entry => ({text: entry.name, value: entry.id})),
initialValues: false,
fallback: [],
}
/**
* Default GraphQL Query `variables` helper to get Entry ID for initial Query
* @example:
* withGql({
* query: {
* variables,
* },
* })
*
* @param {Object} props - from React Component
* @returns {{id: String}|{}} variables - for GraphQL request
*/
export function queryVariables (props) {
const id = props.id || idFromRoute(props)
return (id && id !== ROUTES.NEW && id !== ROUTES.LOGIN) ? {id} : {}
}
/**
* Default GraphQL Query `skip` helper to avoid querying when `variables` is empty
* @example see queryVariables
* @returns {Boolean} true if no ID exists
*/
export function querySkip (props, variables) {
return isEmpty(variables)
}
/**
* Decorator for React Components to Query or Mutate GraphQL API.
* NOTE: use gqlRequestDecorator() creator for consistent and well tested behavior.
* @example:
* // 1. Simple GraphQL decorated view
* *@withGql({query})
* export default class UserProfile extends Component {
* mutate = () => {
* this.props.mutate({variables: {user}})
* ...
* }
*
* // 2. Updating props after query called (for use with Redux Form)
* const props = ({props, props: {initialValues: init = {}}, data: {user}}) => ({
* // To prevent flickering effect when mutation sets loading = true, only change initialValues when needed
* initialValues: !isEqual(props.user, user) ? {...init, ...sanitizeGqlResponse(user || {}, {clone: true})} : init,
* user,
* })
* *@withGql({query: {query, props}, mutation: {mutation, props}})
* *@withForm({form: USER, enableReinitialize: true})
* export default class UserProfile extends Component {
* ...
* }
*
* @note:
* - Query results will be attached directly to Component props, without `data` prop
* - Mutation is attached to Component as props.mutate, for example: this.props.mutate({variables: {user}})
* - If passing {variables} options, `variables` can be a function that receives instance props as argument,
* and returns variables object to be used with query or mutation
*
* @param {Object|Object<query, variables, propsMapper, ...options>} query - Graphql Query DocumentNode (with options)
* @param {Object|Object<mutation, variables, propsMapper, ...options>} mutation - Graphql Mutation DocumentNode (with options)
* @param {Object} [Loading] - Loading React component to render while waiting fetch
* @returns {Function.Class} Decorator - HOC wrapper for Class/Function Component, which returns wrapped Component
* => the original Class (if the Component wrapped was a Class) can be accessed via wrapped Component.Class
*/
export function withGql ({query = null, mutation = null, Loading = LoadingView}) {
return function GqlDecorator (Component) {
let ownProps = {} // persist props as closure object in between component life cycles
let prevURI, uri
let prevQueryLoading = false
let prevMutationLoading = false
let _mutate
let QueryComponent, MutationComponent
// Define mutate wrapper here to avoid causing props change
function mutateWrapper (...args) {
return _mutate.apply(this, [mutation.mutate.apply(this, args)])
}
function GqlQuery (initialProps) {
// Allow chaining requests by overriding on own props only
const props = {...initialProps, ...ownProps, data: {...initialProps.data, ...ownProps.data}}
let errorPolicy
let args = [query]
/* Query Setup */
if (query.query) {
const {query: q, optimistic, ...options} = query
ownProps.optimistic = optimistic
if (isFunction(options.variables)) options.variables = options.variables(props)
if (isFunction(options.skip)) options.skip = options.skip(props, options.variables)
// Provide default Apollo.client so components can render as nested child (i.e. content: () => <Component/>)
if (options.client === undefined) options.client = Active.client
// Fix for chaining queries when used as the last query
// @see: issue: https://github.com/apollographql/react-apollo/issues/3774
if (options.pollInterval == null) options.pollInterval = 0
if (!options.onCompleted) options.onCompleted = () => {}
errorPolicy = options.errorPolicy
args = [q, options]
}
const {loading, error, data, previousData, called: fetched, ...more} = useQuery(...args)
// Let query override props only after a successful HTTP 200 response, cache update, or route changes.
// This avoids overriding nested mutation data when higher up containers,
// like App.js force re-rendering (ex. layout/language/currency change).
// => Also not possible to call .propsMapper for `fallback` value
if (
// successful HTTP 200 response or re-render from `updateCacheList()`
// (`previousData` must exist, else `data` is always different from `previousData`)
(data && fetched && !error && !loading && (prevQueryLoading || (data !== previousData && previousData))) ||
(prevURI && prevURI !== (uri = uriFrom(initialProps))) // route change
) {
prevURI = uri
ownProps = {
...ownProps,
...(query.propsMapper ? query.propsMapper({props, data, fetched, ...more}) : {...data, fetched, ...more})
}
}
prevQueryLoading = loading
const finalProps = {...props, ...ownProps}
const isInit = !ownProps.data // `data` is undefined initially
// If the query (or the entire component) is optimistic, render it immediately.
// Else, only show loading during the first initialization, because the query may be polling in the background.
// @Note: if `next/router` is used, sync it with react-router `location` state API
if (loading && isInit && !ownProps.optimistic && !get(finalProps, 'location.state.optimistic')) return <Loading/>
// Skip rendering on initial error response
if (error && errorPolicy !== 'ignore' && isInit) return null
return <QueryComponent {...finalProps}/>
}
function GqlMutation (initialProps) {
// Allow chaining requests by overriding on own props only
const props = {...initialProps, ...ownProps, data: {...initialProps.data, ...ownProps.data}}
let args = [mutation]
/* Mutation Setup */
if (mutation.mutation) {
const {mutation: m, ...options} = mutation
// Provide default Apollo.client so components can render as nested child (i.e. content: () => <Component/>)
if (options.client === undefined) options.client = Active.client
if (isFunction(options.variables)) options.variables = options.variables(props)
args = [m, options]
}
const [mutate, {loading, error, data, called: saved, ...more}] = useMutation(...args)
ownProps = {...ownProps, mutate, loading, saved} // do not include `error` - it may clash with react-final-form
// Apply form middleware
if (mutation.mutate) {
_mutate = mutate
ownProps.mutate = mutateWrapper
}
// Since propsMapper gets called twice (for query first, then mutation)
// it should only be called when new mutation response comes back.
// Disabling query.propsMapper after mutation is not an option,
// because refetch or polling may need to update props again.
// => Let mutation override props only after a successful HTTP 200 response.
if (prevMutationLoading && !loading && !error && saved && data) {
ownProps = {
...ownProps,
...(mutation.propsMapper ? mutation.propsMapper({props, data, ...more}) : {...data, ...more})
}
}
prevMutationLoading = loading
return <MutationComponent {...props} {...ownProps} />
}
// Recursively attach the original wrapped Class component for access with chained request decorators
const Class = getOriginalClass(Component)
if (Class) {
GqlQuery.Class = Class
GqlMutation.Class = Class
}
// Query may not exist for custom queries, or when only Mutation is needed
if (query && mutation) {
QueryComponent = GqlMutation
MutationComponent = Component
return GqlQuery
}
if (query) {
QueryComponent = Component
return GqlQuery
}
if (mutation) {
MutationComponent = Component
return GqlMutation
}
return Component
}
}
/**
* Create Query and Mutation (using route Id) GraphQL Decorator
* @example:
* // Composable decorator that can be chained with other Entry request decorators
* // @example:
* // @withTagOptions
* // @withCatEditRoute
* // export default class CatEditView extends PureComponent {...}
* export const withTagOptions = gqlRequestDecorator({
* field: TAGS,
* query: tagsSummary,
* // map GQL response to `tagOptions` prop when inside another Entry Component,
* // by default it maps to field.toLowerCase()
* propsMapperOptions: {asProp: 'tagOptions', ...asDropdownOptions},
* reusable,
* })
*
* // Query with Mutation that auto-updates list cache
* export const withTagEditRoute = gqlRequestDecorator({
* field: TAG,
* query,
* mutation,
* update: updateCacheList(withTagOptions, TAG),
* hiddenFields: ['name'] // list of fields to remove from GQL response to sync with formValues
* })
*
* // Simple Query
* export const withTranslations = gqlRequestDecorator({
* field: TRANSLATIONS,
* query: translationsQuery,
* skip: false, // always request to prevent stale cache (even if `variables` is empty/undefined)
* })
*
* @returns {Function<Component>|{asProp, field, query, variables}} Decorator - HOC wrapper for Class/Function Component
*/
export function gqlRequestDecorator ({
// {String} field - GraphQL entry field to be queried/mutated
field,
// {Object} query - imported query.gql file, optional (mutation is expected)
query,
// {Object} mutation - imported mutation.gql file, optional (query is expected)
mutation,
// {Boolean} [reusable] - whether to use default configs for requests without variables that always need querying
reusable,
// {Function<{props, data, fetched, saved, ...useQueryOrMutation}>} [propsMapper] - Component props mapper
propsMapper,
// {Object} [propsMapperOptions] - see `createPropsMapper` for reference
propsMapperOptions,
// {String[]} [hiddenFields] - list of GraphQL Type fields to remove from response to sync with `form` state
hiddenFields,
// {Function<props>|Object} [variables] - initial query variables mapper
variables = reusable ? {} : queryVariables, // GraphQL treats any new empty object {} as the same variables
// @see: https://github.com/apollographql/apollo-client/issues/6760
// 'cache-and-network' literally forces query to refetch after mutation
// {String} [fetchPolicy] - https://www.apollographql.com/docs/react/data/queries/#supported-fetch-policies
fetchPolicy = 'cache-and-network',
// {String} 'cache-first' for mutations with 'cache-and-network' fetchPolicy
nextFetchPolicy = 'cache-first',
// {String} [errorPolicy] - query policy to ignore errors.
// Note: 'ignore' policy does not prevent error popup for 400 errors, it simply returns response `data` with null type value, instead of undefined
errorPolicy,
// {Function<props>} [skip] - return true to skip initial fetching
skip = reusable ? false : querySkip,
// {Boolean} [optimistic] - whether to render the component immediately (good for non-blocking chained requests)
optimistic = reusable,
// {Function<options, instance>|Function[]} [mutate] - wrapper/s around Apollo mutate function, to use as form middleware
mutate,
// {Function|Function[]} [update] - cache after mutation https://www.apollographql.com/docs/react/data/mutations/#usemutation-api
update,
// {Object} [optimisticResponse] - see above link
optimisticResponse,
// {Array<string|{query, variables}>} [refetchQueries] - see above link
refetchQueries,
// {*} [options] - more GQL query options to pass
...options
}) {
field = toLowerCase(field)
// Combine updates
if (isList(update)) {
const updateList = update
update = function (...args) { updateList.forEach(func => func.apply(this, args)) }
}
// Combine mutate form middleware
if (isList(mutate)) {
const mutateList = mutate
mutate = function (...args) { return Object.assign({}, ...mutateList.map(func => func.apply(this, args))) }
}
// Default Props Mapper
if (!propsMapper) {
const config = {field, ...propsMapperOptions}
const _hiddenFields = config.hiddenFields || hiddenFields
config.hiddenFields = _hiddenFields && GQL_HIDDEN_FIELDS.concat(toList(_hiddenFields))
propsMapper = createPropsMapper(config)
}
const closure = {variables: {}}
const decorator = withGql({
query: {
query,
propsMapper,
// Attach variables to Query decorator for Cache readQuery/writeQuery
variables: variables && ((...args) => (closure.variables = (isFunction(variables) ? variables(...args) : variables))),
fetchPolicy,
nextFetchPolicy,
errorPolicy,
skip,
optimistic,
...options,
},
...mutation && {
mutation: {
mutation,
propsMapper,
mutate,
update,
optimisticResponse,
refetchQueries,
}
}
})
if (propsMapperOptions && propsMapperOptions.asProp) decorator.asProp = propsMapperOptions.asProp
Object.defineProperty(decorator, 'variables', {get () {return closure.variables}})
decorator.field = field
decorator.query = query
return decorator
}
/**
* Create `propsMapper` function for use with `gqlRequestDecorator`
* @param {String} field - GraphQL entry field from response to map to props
* @param {String} [asProp] - name of the entry in the props object (defaults to entry `name`)
* @param {Boolean} [initialValues] - whether to map sanitized data to `initialValues` for form state
* @param {String[]} [hiddenFields] - list of GraphQL Type fields to remove from response to sync with `form` state
* @param {Function} [mapEntry] - function to process entry in data response after sanitization
* @param {Function} [mapData] - function to process the entire data response
* @param {*} [fallback] - value to use when entry data is undefined
* @returns {Function<props, data...>} propsMapper
*/
export function createPropsMapper ({
field,
asProp,
initialValues = true,
fallback = {},
hiddenFields,
mapEntry,
mapData
}) {
const _field = `_${asProp || field}`
/**
* @param {Object} props - global component props
* @param {Object|Undefined} data - GraphQL server response
* @param {Function} refetch - query options
*/
return ({props, data = {}, refetch}) => {
const {[field]: entry, ...gqlResponse} = data
// When nothing changed, must return undefined to prevent overriding global props for chained requests
if (props[_field] === entry || isEqual(props[_field], entry)) return
// New props override
let record = sanitizeResponse(entry || fallback, {clone: true, tags: hiddenFields})
if (mapEntry) record = mapEntry(record, props)
return ({
...initialValues && {initialValues: record},
[asProp || field]: record,
[_field]: entry,
// pass around additional responses not in the main entry query
data: {...props.data, ...(mapData ? mapData(data) : gqlResponse)},
refetch
})
}
}
/**
* Create GraphQL propsMapper `mapEntry` function to convert nested Type objects to their IDs
* @example:
* // setup
* gqlRequestDecorator({
* propsMapperOptions: {mapEntry: mapEntryFieldToId(TAGS)},
* })
*
* // usage
* instance.tags = [{id: 'TagID', name: 'Tag Name'}]
* const mapEntry = mapEntryFieldToId('tags')
* mapEntry(instance)
* >>> instance.tags = ['TagID']
*
* @param {String|String[]} fields - path to nested Graphql Types within the entry
* @returns {Function<entry>} mapEntry - function to use with `propsMapper()`
*/
export function mapEntryFieldToId (fields) {
fields = toList(fields).map(toLowerCase)
return function mapEntry (record) {
fields.forEach(field => {
let nestedEntry = get(record, field)
if (!nestedEntry) return
if (isList(nestedEntry)) {
nestedEntry = nestedEntry.map(({id}) => id)
} else if (nestedEntry.id) {
nestedEntry = nestedEntry.id
}
set(record, field, nestedEntry)
})
return record
}
}
/**
* Create GraphQL Mutation `update` function to update the Entry List in Cache
* @example:
* const withTagEditRoute = gqlRequestDecorator({
* ...
* update: updateCacheList(withTagOptions, TAG),
* })
*
* @param {Function<Component>|{field, query, variables}} queryDecorator - that needs update (created by gqlRequestDecorator)
* @param {String|String[]} [mutatedPath] - path to new entry list in mutation result (ex. ['cat', 'tags']), defaults to `field`
* @returns {Function<cache, {data}>} update - https://www.apollographql.com/docs/react/data/mutations/#usemutation-api
*/
export function updateCacheList (queryDecorator, mutatedPath) {
const {field, query} = queryDecorator
// Since most entry types should be a single word, it's common to assume the path as lowercase
const path = mutatedPath ? toList(mutatedPath).map(toLowerCase) : field
return function updateCache (cache, {data}) {
const updatedList = toList(get(data, path), 'clean')
if (!updatedList.length) return
const {variables} = queryDecorator // variables must be retrieved on the fly
const {[field]: cacheList = []} = cache.readQuery({query, variables}) || {} // query may not exist yet
// temporary turn the list into hash map object for fast update
const result = {}
let modified = false
for (const entry of cacheList) {
result[entry.id] = entry
}
for (const entry of updatedList) {
if (isEqual(entry, result[entry.id])) continue
modified = result[entry.id] = {...result[entry.id], ...entry} // merge updates by default, instead of replacing
}
if (modified) cache.writeQuery({
query, variables,
data: {[field]: Object.values(result)},
})
}
}