Skip to content

Commit

Permalink
Initial hooks linter (#52)
Browse files Browse the repository at this point in the history
* Check that a type is passed to useQuery()
* Check that a type is imported for useFragment()
  • Loading branch information
kassens committed Mar 14, 2019
1 parent c56b79e commit b86eb2a
Show file tree
Hide file tree
Showing 2 changed files with 130 additions and 0 deletions.
77 changes: 77 additions & 0 deletions src/rule-generated-flow-types.js
Expand Up @@ -195,6 +195,23 @@ function validateObjectTypeAnnotation(
return true;
}

/**
* Tries to find a GraphQL definition node for a given argument.
* Currently, only supports a graphql`...` literal inline, but could be
* improved to follow a variable definition.
*/
function getDefinitionName(arg) {
if (arg.type !== 'TaggedTemplateExpression') {
// TODO: maybe follow variables, see context.getScope()
return null;
}
const ast = getGraphQLAST(arg);
if (ast == null || ast.definitions.length === 0) {
return null;
}
return ast.definitions[0].name.value;
}

module.exports = {
meta: {
fixable: 'code',
Expand Down Expand Up @@ -226,6 +243,7 @@ module.exports = {
const imports = [];
const requires = [];
const typeAliasMap = {};
const useFragmentInstances = [];
return {
ImportDeclaration(node) {
imports.push(node);
Expand All @@ -242,6 +260,44 @@ module.exports = {
TypeAlias(node) {
typeAliasMap[node.id.name] = node.right;
},

/**
* Find useQuery() calls without type arguments.
*/
'CallExpression[callee.name=useQuery]:not([typeArguments])'(node) {
const firstArg = node.arguments[0];
if (firstArg == null) {
return;
}
const queryName = getDefinitionName(firstArg) || 'ExampleQuery';
context.report({
node: node,
message:
'The `useQuery` hook should be used with an explicit generated Flow type, e.g.: useQuery<{{queryName}}>(...)',
data: {
queryName: queryName
}
});
},

/**
* useFragment() calls
*/
'CallExpression[callee.name=useFragment]'(node) {
const firstArg = node.arguments[0];
if (firstArg == null) {
return;
}
const fragmentName = getDefinitionName(firstArg);
if (fragmentName == null) {
return;
}
useFragmentInstances.push({
fragmentName: fragmentName,
node: node
});
},

ClassDeclaration(node) {
const componentName = node.id.name;
componentMap[componentName] = {
Expand Down Expand Up @@ -282,6 +338,27 @@ module.exports = {
});
},
'Program:exit': function(_node) {
useFragmentInstances.forEach(useFragmentInstance => {
const fragmentName = useFragmentInstance.fragmentName;
const node = useFragmentInstance.node;
const foundImport = imports.find(importDeclaration => {
const importedFromModuleName = importDeclaration.source.value;
return importedFromModuleName.endsWith(fragmentName + '.graphql');
});
if (!foundImport) {
context.report({
node: node,
message:
'The prop passed to useFragment() should be typed with the ' +
'type {{name}} imported from {{name}}.graphql, e.g.:\n' +
'\n' +
" import type {{{name}}} from '{{name}}.graphql;",
data: {
name: fragmentName
}
});
}
});
expectedTypes.forEach(type => {
const componentName = type.split('_')[0];
const propName = type
Expand Down
53 changes: 53 additions & 0 deletions test/test.js
Expand Up @@ -208,6 +208,19 @@ ruleTester.run('generated-flow-types', rules['generated-flow-types'], {
valid: valid.concat([
// syntax error, covered by `graphql-syntax`
{code: 'graphql`query {{{`'},
{
code: `
import type {TestFragment_foo} from 'TestFragment_foo.graphql';
useFragment<FooResponse>(graphql\`query TestFragment_foo { id }\`)
`
},
{
code: `
import type {TestFragment_foo} from './path/to/TestFragment_foo.graphql';
useFragment<FooResponse>(graphql\`query TestFragment_foo { id }\`)
`
},
{code: 'useQuery<FooResponse>(graphql`query Foo { id }`)'},
{
code: `
import type {MyComponent_user} from './__generated__/MyComponent_user.graphql'
Expand Down Expand Up @@ -424,6 +437,46 @@ ruleTester.run('generated-flow-types', rules['generated-flow-types'], {
}
]),
invalid: [
{
code: `
import type {TestFragment_other} from './path/to/TestFragment_other.graphql';
useFragment<FooResponse>(graphql\`query TestFragment_foo { id }\`)
`,
errors: [
{
message: `
The prop passed to useFragment() should be typed with the type TestFragment_foo imported from TestFragment_foo.graphql, e.g.:
import type {TestFragment_foo} from 'TestFragment_foo.graphql;`.trim(),
line: 3,
column: 9
}
]
},
{
code: 'useQuery(graphql`query FooQuery { id }`)',
errors: [
{
message:
'The `useQuery` hook should be used with an explicit generated Flow type, e.g.: useQuery<FooQuery>(...)',
line: 1,
column: 1
}
]
},
{
code: `
const query = graphql\`query FooQuery { id }\`;
useQuery(query);
`,
errors: [
{
message:
'The `useQuery` hook should be used with an explicit generated Flow type, e.g.: useQuery<ExampleQuery>(...)',
line: 3
}
]
},
{
filename: 'MyComponent.jsx',
code: `
Expand Down

0 comments on commit b86eb2a

Please sign in to comment.