-
Notifications
You must be signed in to change notification settings - Fork 787
[WIP/Discussion] Enhance types #394
Changes from all commits
24f3819
99f9968
9a9c283
d91ae89
41ea511
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,9 +1,14 @@ | ||
import { | ||
Component, | ||
ComponentClass, | ||
StatelessComponent, | ||
createElement, | ||
PropTypes, | ||
} from 'react'; | ||
|
||
import { FetchMoreOptions, UpdateQueryOptions } from './../node_modules/apollo-client/core/ObservableQuery'; | ||
import { FetchMoreQueryOptions, SubscribeToMoreOptions } from './../node_modules/apollo-client/core/watchQueryOptions'; | ||
|
||
// modules don't export ES6 modules | ||
import pick = require('lodash.pick'); | ||
import flatten = require('lodash.flatten'); | ||
|
@@ -51,44 +56,63 @@ export declare interface QueryOptions { | |
skip?: boolean; | ||
} | ||
|
||
const defaultMapPropsToOptions = props => ({}); | ||
const defaultMapResultToProps = props => props; | ||
const defaultMapPropsToSkip = props => false; | ||
export type WrappedComponent<T> = ComponentClass<T> | StatelessComponent<T>; | ||
|
||
function defaultMapPropsToOptions<T>(props: T) { | ||
return {}; | ||
} | ||
|
||
function defaultMapResultToProps<T>(props: T) { | ||
return props; | ||
} | ||
|
||
function defaultMapPropsToSkip<T>(props: T) { | ||
return false; | ||
} | ||
|
||
interface ObservableQueryFields { | ||
variables: any; | ||
refetch(variables?: any): Promise<ApolloQueryResult>; | ||
fetchMore(fetchMoreOptions: FetchMoreQueryOptions & FetchMoreOptions): Promise<ApolloQueryResult>; | ||
updateQuery(mapFn: (previousQueryResult: any, options: UpdateQueryOptions) => any): void; | ||
startPolling(pollInterval: number): void; | ||
stopPolling(): void; | ||
subscribeToMore(options: SubscribeToMoreOptions): () => void; | ||
} | ||
// the fields we want to copy over to our data prop | ||
function observableQueryFields(observable) { | ||
const fields = pick(observable, 'variables', | ||
function observableQueryFields(observable: ObservableQuery): ObservableQueryFields { | ||
const fields = pick<ObservableQueryFields, ObservableQuery>(observable, 'variables', | ||
'refetch', 'fetchMore', 'updateQuery', 'startPolling', 'stopPolling', 'subscribeToMore'); | ||
|
||
Object.keys(fields).forEach((key) => { | ||
if (typeof fields[key] === 'function') { | ||
fields[key] = fields[key].bind(observable); | ||
if (typeof (fields as any)[key] === 'function') { | ||
(fields as any)[key] = (fields as any)[key].bind(observable); | ||
} | ||
}); | ||
|
||
return fields; | ||
} | ||
|
||
function getDisplayName(WrappedComponent) { | ||
function getDisplayName<T>(WrappedComponent: WrappedComponent<T>) { | ||
return WrappedComponent.displayName || WrappedComponent.name || 'Component'; | ||
} | ||
|
||
// Helps track hot reloading. | ||
let nextVersion = 0; | ||
|
||
export function withApollo(WrappedComponent) { | ||
export function withApollo<T extends { client: ApolloClient }>(WrappedComponent: WrappedComponent<T>) { | ||
|
||
const withDisplayName = `withApollo(${getDisplayName(WrappedComponent)})`; | ||
|
||
class WithApollo extends Component<any, any> { | ||
class WithApollo extends Component<T, void> { | ||
static displayName = withDisplayName; | ||
static WrappedComponent = WrappedComponent; | ||
static contextTypes = { client: PropTypes.object.isRequired }; | ||
|
||
// data storage | ||
private client: ApolloClient; // apollo client | ||
|
||
constructor(props, context) { | ||
constructor(props: T, context: { client: ApolloClient }) { | ||
super(props, context); | ||
this.client = context.client; | ||
|
||
|
@@ -104,7 +128,7 @@ export function withApollo(WrappedComponent) { | |
render() { | ||
const props = assign({}, this.props); | ||
props.client = this.client; | ||
return createElement(WrappedComponent, props); | ||
return createElement(WrappedComponent as React.ComponentClass<T>, props); | ||
} | ||
} | ||
|
||
|
@@ -114,7 +138,7 @@ export function withApollo(WrappedComponent) { | |
|
||
export interface OperationOption { | ||
options?: Object | ((props: any) => QueryOptions | MutationOptions); | ||
props?: (props: any) => any; | ||
props?: <TOwnProps, TMappedProps>(props: TOwnProps) => TMappedProps; | ||
skip?: boolean | ((props: any) => boolean); | ||
name?: string; | ||
withRef?: boolean; | ||
|
@@ -142,11 +166,11 @@ export default function graphql( | |
|
||
// Helps track hot reloading. | ||
const version = nextVersion++; | ||
return function wrapWithApolloComponent(WrappedComponent) { | ||
return function wrapWithApolloComponent<T>(WrappedComponent: WrappedComponent<T>) { | ||
|
||
const graphQLDisplayName = `Apollo(${getDisplayName(WrappedComponent)})`; | ||
|
||
class GraphQL extends Component<any, any> { | ||
class GraphQL extends Component<T, any> { | ||
static displayName = graphQLDisplayName; | ||
static WrappedComponent = WrappedComponent; | ||
static contextTypes = { | ||
|
@@ -155,7 +179,7 @@ export default function graphql( | |
}; | ||
|
||
// react / redux and react dev tools (HMR) needs | ||
public props: any; // passed props | ||
public props: T; // passed props | ||
public version: number; | ||
public hasMounted: boolean; | ||
|
||
|
@@ -168,16 +192,16 @@ export default function graphql( | |
// unsubscribe but never delete queryObservable once it is created. | ||
private queryObservable: ObservableQuery | any; | ||
private querySubscription: Subscription; | ||
private previousData: any = {}; | ||
private lastSubscriptionData: any; | ||
private previousData: { [i: string]: any } = {}; | ||
private lastSubscriptionData: { [i: string]: any }; | ||
|
||
// calculated switches to control rerenders | ||
private shouldRerender: boolean; | ||
|
||
// the element to render | ||
private renderedElement: any; | ||
|
||
constructor(props, context) { | ||
constructor(props: T, context: any) { | ||
super(props, context); | ||
this.version = version; | ||
this.client = context.client; | ||
|
@@ -205,7 +229,7 @@ export default function graphql( | |
} | ||
} | ||
|
||
componentWillReceiveProps(nextProps) { | ||
componentWillReceiveProps(nextProps: T) { | ||
if (shallowEqual(this.props, nextProps)) return; | ||
|
||
this.shouldRerender = true; | ||
|
@@ -234,7 +258,7 @@ export default function graphql( | |
this.subscribeToQuery(); | ||
} | ||
|
||
shouldComponentUpdate(nextProps, nextState, nextContext) { | ||
shouldComponentUpdate(nextProps: T, nextState: any, nextContext: any) { | ||
return !!nextContext || this.shouldRerender; | ||
} | ||
|
||
|
@@ -245,7 +269,7 @@ export default function graphql( | |
this.hasMounted = false; | ||
} | ||
|
||
calculateOptions(props = this.props, newOpts?) { | ||
calculateOptions(props = this.props, newOpts?: any) { | ||
let opts = mapPropsToOptions(props); | ||
|
||
if (newOpts && newOpts.variables) { | ||
|
@@ -263,18 +287,18 @@ export default function graphql( | |
for (let { variable, type } of operation.variables) { | ||
if (!variable.name || !variable.name.value) continue; | ||
|
||
if (typeof props[variable.name.value] !== 'undefined') { | ||
variables[variable.name.value] = props[variable.name.value]; | ||
if (typeof (props as any)[variable.name.value] !== 'undefined') { | ||
(variables as any)[variable.name.value] = (props as any)[variable.name.value]; | ||
continue; | ||
} | ||
|
||
// allow optional props | ||
if (type.kind !== 'NonNullType') { | ||
variables[variable.name.value] = null; | ||
(variables as any)[variable.name.value] = null; | ||
continue; | ||
} | ||
|
||
invariant(typeof props[variable.name.value] !== 'undefined', | ||
invariant(typeof (props as any)[variable.name.value] !== 'undefined', | ||
`The operation '${operation.name}' wrapping '${getDisplayName(WrappedComponent)}' ` + | ||
`is expecting a variable: '${variable.name.value}' but it was not found in the props ` + | ||
`passed to '${graphQLDisplayName}'` | ||
|
@@ -284,14 +308,27 @@ export default function graphql( | |
return opts; | ||
}; | ||
|
||
calculateResultProps(result) { | ||
let name = this.type === DocumentType.Mutation ? 'mutate' : 'data'; | ||
if (operationOptions.name) name = operationOptions.name; | ||
|
||
const newResult = { [name]: result, ownProps: this.props }; | ||
if (mapResultToProps) return mapResultToProps(newResult); | ||
calculateResultProps<T>(result: T) { | ||
// Ugly, but the hope is to allow typescript to do control-flow analysis | ||
// to determine if `data` or `mutate` are the keys | ||
if (operationOptions.name != null) { | ||
let name = operationOptions.name; | ||
const newResult = { [name]: result, ownProps: this.props }; | ||
// Prevents us inferring useful type information :/ | ||
if (mapResultToProps) return mapResultToProps<typeof newResult, { [i: string]: any }>(newResult); | ||
|
||
return { [name]: defaultMapResultToProps(result) }; | ||
} else if (this.type === DocumentType.Mutation) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is another cash that prevents us from deducing the type information easily. Since the DocumentType is determined at runtime, we have to dynamically assign the key as either Another (possibly better in the long term) solution would be to separate the |
||
const newResult = { mutate: result, ownProps: this.props }; | ||
if (mapResultToProps) return mapResultToProps<typeof newResult, { [i: string]: any }>(newResult); | ||
|
||
return { mutate: defaultMapResultToProps(result) }; | ||
} else { | ||
const newResult = { data: result, ownProps: this.props }; | ||
if (mapResultToProps) return mapResultToProps<typeof newResult, { [i: string]: any }>(newResult); | ||
|
||
return { [name]: defaultMapResultToProps(result) }; | ||
return { data: defaultMapResultToProps(result) }; | ||
} | ||
} | ||
|
||
setInitialProps() { | ||
|
@@ -323,7 +360,7 @@ export default function graphql( | |
} | ||
} | ||
|
||
updateQuery(props) { | ||
updateQuery(props: T) { | ||
const opts = this.calculateOptions(props) as QueryOptions; | ||
|
||
// if we skipped initially, we may not have yet created the observable | ||
|
@@ -387,7 +424,7 @@ export default function graphql( | |
this.forceRenderChildren(); | ||
}; | ||
|
||
const handleError = (error) => { | ||
const handleError = (error: any) => { | ||
// Quick fix for https://github.com/apollostack/react-apollo/issues/378 | ||
if (error.hasOwnProperty('graphQLErrors')) return next({ error }); | ||
throw error; | ||
|
@@ -443,35 +480,36 @@ export default function graphql( | |
} | ||
|
||
const opts = this.calculateOptions(this.props); | ||
const data = {}; | ||
assign(data, observableQueryFields(this.queryObservable)); | ||
const data = assign({}, observableQueryFields(this.queryObservable)); | ||
|
||
type ResultData = { [i: string]: any }; | ||
|
||
if (this.type === DocumentType.Subscription) { | ||
assign(data, { | ||
return assign(data, { | ||
loading: !this.lastSubscriptionData, | ||
variables: opts.variables, | ||
}, this.lastSubscriptionData); | ||
}, this.lastSubscriptionData as ResultData); | ||
|
||
} else { | ||
// fetch the current result (if any) from the store | ||
const currentResult = this.queryObservable.currentResult(); | ||
const { loading, error, networkStatus } = currentResult; | ||
assign(data, { loading, error, networkStatus }); | ||
const dataWithCurrentResult = assign(data, { loading, error, networkStatus }); | ||
|
||
if (loading) { | ||
// while loading, we should use any previous data we have | ||
assign(data, this.previousData, currentResult.data); | ||
return assign(dataWithCurrentResult, this.previousData, currentResult.data as ResultData); | ||
} else { | ||
assign(data, currentResult.data); | ||
const result = assign(dataWithCurrentResult, currentResult.data as ResultData); | ||
this.previousData = currentResult.data; | ||
return result; | ||
} | ||
} | ||
return data; | ||
} | ||
|
||
render() { | ||
if (this.shouldSkip()) { | ||
return createElement(WrappedComponent, this.props); | ||
return createElement(WrappedComponent as React.ComponentClass<T>, this.props); | ||
} | ||
|
||
const { shouldRerender, renderedElement, props } = this; | ||
|
@@ -485,14 +523,16 @@ export default function graphql( | |
return renderedElement; | ||
} | ||
|
||
if (operationOptions.withRef) mergedPropsAndData.ref = 'wrappedInstance'; | ||
this.renderedElement = createElement(WrappedComponent, mergedPropsAndData); | ||
if (operationOptions.withRef) mergedPropsAndData['ref'] = 'wrappedInstance'; | ||
this.renderedElement = createElement(WrappedComponent as React.ComponentClass<T>, mergedPropsAndData); | ||
|
||
return this.renderedElement; | ||
} | ||
} | ||
|
||
// Make sure we preserve any custom statics on the original component. | ||
return hoistNonReactStatics(GraphQL, WrappedComponent, {}); | ||
}; | ||
hoistNonReactStatics(GraphQL, WrappedComponent, {}); | ||
|
||
return GraphQL as typeof WrappedComponent; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Two issues:
|
||
}; | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is currently preventing us from inferring the type of the result props since the user can feasibly map them to whatever they desire. Perhaps there is a niftier way to do this?