import * as React from 'react'; import { graphql } from 'react-apollo'; import merge from 'lodash/merge'; import uniq from 'lodash/uniq'; import flatten from 'lodash/flatten'; import isEmpty from 'lodash/isEmpty'; import { getDefinitionName, getResponseErrors, getErrorMessages, } from '../utils'; import PropTypes from 'prop-types'; import t from 'coral-framework/services/i18n'; import hoistStatics from 'recompose/hoistStatics'; import union from 'lodash/union'; import { notify } from 'coral-framework/actions/notification'; class ResponseErrors extends Error { constructor(errors) { super(`Response Errors ${JSON.stringify(errors)}`); this.errors = errors.map(e => new ResponseError(e)); } } class ResponseError { constructor(error) { Object.assign(this, error); } translate(...args) { return t(`error.${this.translation_key}`, ...args); } } const createHOC = (document, config, { notifyOnError = true }) => hoistStatics(WrappedComponent => { config = { ...config, options: config.options || {}, props: config.props || (data => ({ mutate: data.mutate() })), }; return class WithMutation extends React.Component { static contextTypes = { eventEmitter: PropTypes.object, store: PropTypes.object, graphql: PropTypes.object, }; static propTypes = { notify: PropTypes.func, }; get graphqlRegistry() { return this.context.graphql.registry; } notifyErrors(messages) { this.context.store.dispatch(notify('error', messages)); } resolveDocument(documentOrCallback) { return this.context.graphql.resolveDocument( documentOrCallback, this.props, this.context ); } // Lazily resolve fragments from graphRegistry to support circular dependencies. memoized = null; // Props as we would pass to the BaseComponent without optimizations. dynamicProps = {}; // Props that are optimized by keeping the identity of function callbacks. staticProps = {}; propsWrapper = data => { const name = getDefinitionName(document); const callbacks = this.graphqlRegistry.getMutationOptions(name); const mutate = base => { const variables = base.variables || config.options.variables; const configs = callbacks.map(cb => cb({ variables, state: this.context.store.getState() }) ); const optimisticResponse = merge( base.optimisticResponse || config.options.optimisticResponse, ...configs.map(cfg => cfg.optimisticResponse) ); const refetchQueries = flatten( uniq( [ base.refetchQueries || config.options.refetchQueries, ...configs.map(cfg => cfg.refetchQueries), ].filter(i => i) ) ); const updateCallbacks = [base.update || config.options.update] .concat(...configs.map(cfg => cfg.update)) .filter(i => i); const update = (proxy, result) => { if (getResponseErrors(result)) { // Do not run updates when we have mutation errors. return; } updateCallbacks.forEach(cb => cb(proxy, result)); }; const updateQueries = [ base.updateQueries || config.options.updateQueries, ...configs.map(cfg => cfg.updateQueries), ] .filter(i => i) .reduce((res, map) => { Object.keys(map).forEach(key => { if (!(key in res)) { res[key] = (prev, result) => { if (getResponseErrors(result.mutationResult)) { // Do not run updates when we have mutation errors. return prev; } return map[key](prev, result) || prev; }; } else { const existing = res[key]; res[key] = (prev, result) => { if (getResponseErrors(result.mutationResult)) { // Do not run updates when we have mutation errors. return prev; } const next = existing(prev, result); return map[key](next, result) || next; }; } }); return res; }, {}); const wrappedConfig = { variables, optimisticResponse, refetchQueries, updateQueries, update, }; if (isEmpty(wrappedConfig.optimisticResponse)) { delete wrappedConfig.optimisticResponse; } this.context.eventEmitter.emit(`mutation.${name}.begin`, { variables, }); return data .mutate(wrappedConfig) .then(res => { const errors = getResponseErrors(res); if (errors) { throw new ResponseErrors(errors); } this.context.eventEmitter.emit(`mutation.${name}.success`, { variables, data: res.data, }); return Promise.resolve(res); }) .catch(error => { this.context.eventEmitter.emit(`mutation.${name}.error`, { variables, error, }); // Show errors as notifications. if (notifyOnError) { this.notifyErrors(getErrorMessages(error)); } throw error; }); }; // Save current props to `dynamicProps` this.dynamicProps = config.props({ ...data, mutate }); // Sync props to `staticProps`. // `staticProps` ultimately contains the same props as `dynamicProps` but all callbacks // keep their identity. union( Object.keys(this.dynamicProps), Object.keys(this.staticProps) ).forEach(key => { if (!(key in this.dynamicProps)) { delete this.staticProps[key]; return; } if (typeof this.dynamicProps[key] !== 'function') { this.staticProps[key] = this.dynamicProps[key]; return; } if (!(key in this.staticProps)) { this.staticProps[key] = (...args) => this.dynamicProps[key](...args); return; } }); return this.staticProps; }; getWrapped = () => { if (!this.memoized) { this.memoized = graphql(this.resolveDocument(document), { ...config, props: this.propsWrapper, })(WrappedComponent); } return this.memoized; }; render() { const Wrapped = this.getWrapped(); return ; } }; }); /** * Exports a HOC with the same signature as `graphql`, that will * apply mutation options registered in the graphRegistry. * * The returned HOC accepts a settings object with the following properties: * notifyOnError: show a notification to the user when an error occured. * Defaults to true. */ export default (document, config = {}) => settingsOrComponent => { if (typeof settingsOrComponent === 'function') { return createHOC(document, config, {})(settingsOrComponent); } return createHOC(document, config, settingsOrComponent); };