Skip to content
This repository has been archived by the owner on Apr 13, 2023. It is now read-only.

[WIP/Discussion] Enhance types #394

Closed
wants to merge 5 commits into from
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 88 additions & 48 deletions src/graphql.tsx
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');
Expand Down Expand Up @@ -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;

Expand All @@ -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);
}
}

Expand All @@ -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;
Expand Down Expand Up @@ -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 = {
Expand All @@ -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;

Expand All @@ -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;
Expand Down Expand Up @@ -205,7 +229,7 @@ export default function graphql(
}
}

componentWillReceiveProps(nextProps) {
componentWillReceiveProps(nextProps: T) {
if (shallowEqual(this.props, nextProps)) return;

this.shouldRerender = true;
Expand Down Expand Up @@ -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;
}

Expand All @@ -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) {
Expand All @@ -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}'`
Expand All @@ -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);
Copy link
Author

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?


return { [name]: defaultMapResultToProps(result) };
} else if (this.type === DocumentType.Mutation) {
Copy link
Author

Choose a reason for hiding this comment

The 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 mutate or data. There is potentially a way to solve this via Type Guards, but I am not 100% sure it will work as expected.

Another (possibly better in the long term) solution would be to separate the graphql function into two functions: query and mutate. This way, a lot less work is spent determining things at runtime; and we can make better guarantees about the types.

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() {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two issues:

  1. Since we currently cannot infer the types of the props that are decorated, I am simply using the type of the wrapped component here. Obviously, this gives us nothing w/r/t the types that are passed in; but as it stands the user was not getting any of this information to begin with.

  2. I cannot return GraphQL directly since the type is not exported. And I do not know how to export the type 😕

};
};