diff --git a/packages/plugins/typescript/urql/.gitignore b/packages/plugins/typescript/urql/.gitignore new file mode 100644 index 00000000000..911ea64d58a --- /dev/null +++ b/packages/plugins/typescript/urql/.gitignore @@ -0,0 +1,5 @@ +node_modules +npm-debug.log +dist +temp +yarn-error.log \ No newline at end of file diff --git a/packages/plugins/typescript/urql/.npmignore b/packages/plugins/typescript/urql/.npmignore new file mode 100644 index 00000000000..7abfc7c3714 --- /dev/null +++ b/packages/plugins/typescript/urql/.npmignore @@ -0,0 +1,5 @@ +src +node_modules +tests +!dist +example \ No newline at end of file diff --git a/packages/plugins/typescript/urql/package.json b/packages/plugins/typescript/urql/package.json new file mode 100644 index 00000000000..b8c4af8b242 --- /dev/null +++ b/packages/plugins/typescript/urql/package.json @@ -0,0 +1,41 @@ +{ + "name": "@graphql-codegen/typescript-urql", + "version": "1.1.3", + "description": "GraphQL Code Generator plugin for generating a ready-to-use React Components/HOC/Hooks based on GraphQL operations with urql", + "repository": "git@github.com:dotansimha/graphql-code-generator.git", + "license": "MIT", + "scripts": { + "build": "tsc -m esnext --outDir dist/esnext && tsc -m commonjs --outDir dist/commonjs", + "test": "jest --config ../../../../jest.config.js" + }, + "peerDependencies": { + "graphql-tag": "^2.0.0" + }, + "dependencies": { + "@graphql-codegen/plugin-helpers": "1.1.3", + "@graphql-codegen/visitor-plugin-common": "1.1.3", + "tslib": "1.9.3" + }, + "devDependencies": { + "@graphql-codegen/testing": "1.1.3", + "flow-bin": "0.98.0", + "flow-parser": "0.98.0", + "graphql": "14.2.1", + "jest": "24.8.0", + "ts-jest": "24.0.2", + "typescript": "3.4.5" + }, + "sideEffects": false, + "main": "dist/commonjs/index.js", + "module": "dist/esnext/index.js", + "typings": "dist/esnext/index.d.ts", + "typescript": { + "definition": "dist/esnext/index.d.ts" + }, + "jest-junit": { + "outputDirectory": "../../../../test-results/typescript-react-apollo" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/plugins/typescript/urql/src/index.ts b/packages/plugins/typescript/urql/src/index.ts new file mode 100644 index 00000000000..c8deb39cfc2 --- /dev/null +++ b/packages/plugins/typescript/urql/src/index.ts @@ -0,0 +1,87 @@ +import { Types, PluginValidateFn, PluginFunction } from '@graphql-codegen/plugin-helpers'; +import { visit, GraphQLSchema, concatAST, Kind, FragmentDefinitionNode } from 'graphql'; +import { RawClientSideBasePluginConfig } from '@graphql-codegen/visitor-plugin-common'; +import { UrqlVisitor } from './visitor'; +import { extname } from 'path'; + +export interface UrqlRawPluginConfig extends RawClientSideBasePluginConfig { + /** + * @name withComponent + * @type boolean + * @description Customized the output by enabling/disabling the generated Component. + * @default true + * + * @example + * ```yml + * generates: + * path/to/file.ts: + * plugins: + * - typescript + * - typescript-operations + * - typescript-react-apollo + * config: + * withComponent: false + * ``` + */ + withComponent?: boolean; + /** + * @name withHooks + * @type boolean + * @description Customized the output by enabling/disabling the generated React Hooks. + * @default false + * + * @example + * ```yml + * generates: + * path/to/file.ts: + * plugins: + * - typescript + * - typescript-operations + * - typescript-react-apollo + * config: + * withHooks: false + * ``` + */ + withHooks?: boolean; + + /** + * @name urqlImportFrom + * @type string + * @description You can specify module that exports components `Query`, `Mutation`, `Subscription` and HOCs + * This is useful for further abstraction of some common tasks (eg. error handling). + * Filepath relative to generated file can be also specified. + * @default urql + */ + urqlImportFrom?: string; +} + +export const plugin: PluginFunction = (schema: GraphQLSchema, documents: Types.DocumentFile[], config: UrqlRawPluginConfig) => { + const allAst = concatAST( + documents.reduce((prev, v) => { + return [...prev, v.content]; + }, []) + ); + const operationsCount = allAst.definitions.filter(d => d.kind === Kind.OPERATION_DEFINITION); + + if (operationsCount.length === 0) { + return ''; + } + + const allFragments = allAst.definitions.filter(d => d.kind === Kind.FRAGMENT_DEFINITION) as FragmentDefinitionNode[]; + const visitor = new UrqlVisitor(allFragments, config) as any; + const visitorResult = visit(allAst, { leave: visitor }); + + return [visitor.getImports(), visitor.fragments, ...visitorResult.definitions.filter(t => typeof t === 'string')].join('\n'); +}; + +export const validate: PluginValidateFn = async (schema: GraphQLSchema, documents: Types.DocumentFile[], config: UrqlRawPluginConfig, outputFile: string) => { + if (config.withComponent === false) { + if (extname(outputFile) !== '.ts' && extname(outputFile) !== '.tsx') { + throw new Error(`Plugin "react-apollo" with "noComponents" requires extension to be ".ts" or ".tsx"!`); + } + } else { + if (extname(outputFile) !== '.tsx') { + throw new Error(`Plugin "react-apollo" requires extension to be ".tsx"!`); + } + } +}; diff --git a/packages/plugins/typescript/urql/src/visitor.ts b/packages/plugins/typescript/urql/src/visitor.ts new file mode 100644 index 00000000000..b6347df2ee9 --- /dev/null +++ b/packages/plugins/typescript/urql/src/visitor.ts @@ -0,0 +1,74 @@ +import { ClientSideBaseVisitor, ClientSideBasePluginConfig, getConfigValue } from '@graphql-codegen/visitor-plugin-common'; +import { UrqlRawPluginConfig } from './index'; +import * as autoBind from 'auto-bind'; +import { FragmentDefinitionNode, OperationDefinitionNode, Kind } from 'graphql'; +import { titleCase } from 'change-case'; + +export interface UrqlPluginConfig extends ClientSideBasePluginConfig { + withComponent: boolean; + withHooks: boolean; + urqlImportFrom: string; +} + +export class UrqlVisitor extends ClientSideBaseVisitor { + constructor(fragments: FragmentDefinitionNode[], rawConfig: UrqlRawPluginConfig) { + super(fragments, rawConfig, { + withComponent: getConfigValue(rawConfig.withComponent, true), + withHooks: getConfigValue(rawConfig.withHooks, false), + urqlImportFrom: getConfigValue(rawConfig.urqlImportFrom, null), + }); + + autoBind(this); + } + + public getImports(): string { + const baseImports = super.getImports(); + const imports = []; + + if (this.config.withComponent) { + imports.push(`import * as React from 'react';`); + } + + if (this.config.withComponent || this.config.withHooks) { + imports.push(`import * as Urql from '${this.config.urqlImportFrom || 'urql'}';`); + } + + imports.push(`export type Omit = Pick>`); + + return [baseImports, ...imports].join('\n'); + } + + private _buildComponent(node: OperationDefinitionNode, documentVariableName: string, operationType: string, operationResultType: string, operationVariablesTypes: string): string { + const componentName: string = this.convertName(node.name.value, { suffix: 'Component', useTypesPrefix: false }); + + const isVariablesRequired = operationType === 'Query' && node.variableDefinitions.some(variableDef => variableDef.type.kind === Kind.NON_NULL_TYPE); + + return ` +export const ${componentName} = (props: Omit & { variables${isVariablesRequired ? '' : '?'}: ${operationVariablesTypes} }) => ( + +); +`; + } + + private _buildHooks(node: OperationDefinitionNode, operationType: string, documentVariableName: string, operationResultType: string, operationVariablesTypes: string): string { + const operationName: string = this.convertName(node.name.value, { suffix: titleCase(operationType), useTypesPrefix: false }); + + if (operationType === 'Mutation') { + return ` +export function use${operationName}() { + return Urql.use${operationType}<${operationResultType}>(${documentVariableName}); +};`; + } + return ` +export function use${operationName}(options: Urql.Use${operationType}Args<${operationVariablesTypes}> = {}) { + return Urql.use${operationType}<${operationResultType}>({ query: ${documentVariableName}, ...options }); +};`; + } + + protected buildOperation(node: OperationDefinitionNode, documentVariableName: string, operationType: string, operationResultType: string, operationVariablesTypes: string): string { + const component = this.config.withComponent ? this._buildComponent(node, documentVariableName, operationType, operationResultType, operationVariablesTypes) : null; + const hooks = this.config.withHooks ? this._buildHooks(node, operationType, documentVariableName, operationResultType, operationVariablesTypes) : null; + + return [component, hooks].filter(a => a).join('\n'); + } +} diff --git a/packages/plugins/typescript/urql/tests/urql.spec.ts b/packages/plugins/typescript/urql/tests/urql.spec.ts new file mode 100644 index 00000000000..24b9237b7af --- /dev/null +++ b/packages/plugins/typescript/urql/tests/urql.spec.ts @@ -0,0 +1,576 @@ +import '@graphql-codegen/testing'; +import { plugin } from '../src/index'; +import { parse, GraphQLSchema, buildClientSchema, buildASTSchema } from 'graphql'; +import gql from 'graphql-tag'; +import { Types } from '@graphql-codegen/plugin-helpers'; +import { plugin as tsPlugin } from '@graphql-codegen/typescript/src'; +import { plugin as tsDocumentsPlugin } from '../../operations/src/index'; +import { validateTs } from '@graphql-codegen/typescript/tests/validate'; +import { readFileSync } from 'fs'; + +describe('urql', () => { + const schema = buildClientSchema(JSON.parse(readFileSync('../../../../dev-test/githunt/schema.json').toString())); + const basicDoc = parse(/* GraphQL */ ` + query test { + feed { + id + commentCount + repository { + full_name + html_url + owner { + avatar_url + } + } + } + } + `); + + const validateTypeScript = async (output: string, testSchema: GraphQLSchema, documents: Types.DocumentFile[], config: any) => { + const tsOutput = await tsPlugin(testSchema, documents, config, { outputFile: '' }); + const tsDocumentsOutput = await tsDocumentsPlugin(testSchema, documents, config, { outputFile: '' }); + const merged = [tsOutput, tsDocumentsOutput, output].join('\n'); + validateTs(merged, undefined, true); + }; + + it(`should skip if there's no operations`, async () => { + const content = await plugin( + schema, + [], + {}, + { + outputFile: 'graphql.tsx', + } + ); + + expect(content).not.toContain(`import * as ReactApollo from 'react-apollo';`); + expect(content).not.toContain(`import * as React from 'react';`); + expect(content).not.toContain(`import gql from 'graphql-tag';`); + await validateTypeScript(content, schema, [], {}); + }); + + describe('Imports', () => { + it('should import Urql dependencies', async () => { + const docs = [{ filePath: '', content: basicDoc }]; + const content = await plugin( + schema, + docs, + {}, + { + outputFile: 'graphql.tsx', + } + ); + + expect(content).toBeSimilarStringTo(`import * as Urql from 'urql';`); + expect(content).toBeSimilarStringTo(`import * as React from 'react';`); + expect(content).toBeSimilarStringTo(`import gql from 'graphql-tag';`); + await validateTypeScript(content, schema, docs, {}); + }); + + it('should import DocumentNode when using noGraphQLTag', async () => { + const docs = [{ filePath: '', content: basicDoc }]; + const content = await plugin( + schema, + docs, + { + noGraphQLTag: true, + }, + { + outputFile: 'graphql.tsx', + } + ); + + expect(content).toContain(`import { DocumentNode } from 'graphql';`); + expect(content).not.toBeSimilarStringTo(`import gql from 'graphql-tag';`); + await validateTypeScript(content, schema, docs, {}); + }); + + it(`should use gql import from gqlImport config option`, async () => { + const docs = [{ filePath: '', content: basicDoc }]; + const content = await plugin( + schema, + docs, + { gqlImport: 'graphql.macro#gql' }, + { + outputFile: 'graphql.tsx', + } + ); + + expect(content).toContain(`import { gql } from 'graphql.macro';`); + await validateTypeScript(content, schema, docs, {}); + }); + + it('should import Urql from urqlImportFrom config option', async () => { + const docs = [{ filePath: '', content: basicDoc }]; + const content = await plugin( + schema, + docs, + { withHooks: true, urqlImportFrom: 'custom-urql' }, + { + outputFile: 'graphql.tsx', + } + ); + + expect(content).toBeSimilarStringTo(`import * as Urql from 'custom-urql';`); + await validateTypeScript(content, schema, docs, {}); + }); + }); + + describe('Fragments', () => { + it('Should generate basic fragments documents correctly', async () => { + const docs = [ + { + filePath: 'a.graphql', + content: parse(/* GraphQL */ ` + fragment MyFragment on Repository { + full_name + } + + query { + feed { + id + } + } + `), + }, + ]; + const result = await plugin(schema, docs, {}, { outputFile: '' }); + + expect(result).toBeSimilarStringTo(` + export const MyFragmentFragmentDoc = gql\` + fragment MyFragment on Repository { + full_name + } + \`;`); + await validateTypeScript(result, schema, docs, {}); + }); + + it('should generate Document variables for inline fragments', async () => { + const repositoryWithOwner = gql` + fragment RepositoryWithOwner on Repository { + full_name + html_url + owner { + avatar_url + } + } + `; + const feedWithRepository = gql` + fragment FeedWithRepository on Entry { + id + commentCount + repository(search: "phrase") { + ...RepositoryWithOwner + } + } + + ${repositoryWithOwner} + `; + const myFeed = gql` + query MyFeed { + feed { + ...FeedWithRepository + } + } + + ${feedWithRepository} + `; + + const docs = [{ filePath: '', content: myFeed }]; + + const content = await plugin( + schema, + docs, + {}, + { + outputFile: 'graphql.tsx', + } + ); + + expect(content).toBeSimilarStringTo(`export const FeedWithRepositoryFragmentDoc = gql\` +fragment FeedWithRepository on Entry { + id + commentCount + repository(search: "phrase") { + ...RepositoryWithOwner + } +} +\${RepositoryWithOwnerFragmentDoc}\`;`); + expect(content).toBeSimilarStringTo(`export const RepositoryWithOwnerFragmentDoc = gql\` +fragment RepositoryWithOwner on Repository { + full_name + html_url + owner { + avatar_url + } +} +\`;`); + + expect(content).toBeSimilarStringTo(`export const MyFeedDocument = gql\` +query MyFeed { + feed { + ...FeedWithRepository + } +} +\${FeedWithRepositoryFragmentDoc}\`;`); + await validateTypeScript(content, schema, docs, {}); + }); + + it('should avoid generating duplicate fragments', async () => { + const simpleFeed = gql` + fragment Item on Entry { + id + } + `; + const myFeed = gql` + query MyFeed { + feed { + ...Item + } + allFeeds: feed { + ...Item + } + } + `; + const documents = [simpleFeed, myFeed]; + const docs = documents.map(content => ({ content, filePath: '' })); + const content = await plugin( + schema, + docs, + {}, + { + outputFile: 'graphql.tsx', + } + ); + + expect(content).toBeSimilarStringTo(` + export const MyFeedDocument = gql\` + query MyFeed { + feed { + ...Item + } + allFeeds: feed { + ...Item + } + } + \${ItemFragmentDoc}\``); + expect(content).toBeSimilarStringTo(` + export const ItemFragmentDoc = gql\` + fragment Item on Entry { + id + } +\`;`); + await validateTypeScript(content, schema, docs, {}); + }); + + it('Should generate fragments in proper order (when one depends on other)', async () => { + const myFeed = gql` + fragment FeedWithRepository on Entry { + id + repository { + ...RepositoryWithOwner + } + } + + fragment RepositoryWithOwner on Repository { + full_name + } + + query MyFeed { + feed { + ...FeedWithRepository + } + } + `; + const documents = [myFeed]; + const docs = documents.map(content => ({ content, filePath: '' })); + const content = await plugin( + schema, + docs, + {}, + { + outputFile: 'graphql.tsx', + } + ); + + const feedWithRepositoryPos = content.indexOf('fragment FeedWithRepository'); + const repositoryWithOwnerPos = content.indexOf('fragment RepositoryWithOwner'); + expect(repositoryWithOwnerPos).toBeLessThan(feedWithRepositoryPos); + await validateTypeScript(content, schema, docs, {}); + }); + }); + + describe('Component', () => { + it('should generate Document variable', async () => { + const docs = [{ filePath: '', content: basicDoc }]; + const content = await plugin( + schema, + docs, + {}, + { + outputFile: 'graphql.tsx', + } + ); + + expect(content).toBeSimilarStringTo(` + export const TestDocument = gql\` + query test { + feed { + id + commentCount + repository { + full_name + html_url + owner { + avatar_url + } + } + } + } + \`; + `); + await validateTypeScript(content, schema, docs, {}); + }); + + it('should generate Document variable with noGraphQlTag', async () => { + const docs = [{ filePath: '', content: basicDoc }]; + const content = await plugin( + schema, + docs, + { + noGraphQLTag: true, + }, + { + outputFile: 'graphql.tsx', + } + ); + + expect(content).toBeSimilarStringTo(`export const TestDocument: DocumentNode = {"kind":"Document","defin`); + + // For issue #1599 - make sure there are not `loc` properties + expect(content).not.toContain(`loc":`); + expect(content).not.toContain(`loc':`); + + await validateTypeScript(content, schema, docs, {}); + }); + + it('should generate Component', async () => { + const docs = [{ filePath: '', content: basicDoc }]; + const content = await plugin( + schema, + docs, + {}, + { + outputFile: 'graphql.tsx', + } + ); + + expect(content).toBeSimilarStringTo(` + export const TestComponent = (props: Omit & { variables?: TestQueryVariables }) => + ( + + ); + `); + await validateTypeScript(content, schema, docs, {}); + }); + + it('should not generate Component', async () => { + const docs = [{ filePath: '', content: basicDoc }]; + const content = await plugin( + schema, + docs, + { withComponent: false }, + { + outputFile: 'graphql.tsx', + } + ); + + expect(content).not.toContain(`export class TestComponent`); + await validateTypeScript(content, schema, docs, {}); + }); + + it('should make variables property required if any of variable definitions is non-null', async () => { + const docs = [ + { + filePath: '', + content: gql` + query Test($foo: String!) { + test(foo: $foo) + } + `, + }, + ]; + const schema = buildASTSchema(gql` + type Query { + test(foo: String!): Boolean + } + `); + const content = await plugin( + schema, + docs, + {}, + { + outputFile: 'graphql.tsx', + } + ); + + expect(content).toBeSimilarStringTo(` + export const TestComponent = (props: Omit & { variables: TestQueryVariables }) => ( + + );`); + await validateTypeScript(content, schema, docs, {}); + }); + + it('should make variables property optional if operationType is mutation', async () => { + const docs = [ + { + filePath: '', + content: gql` + mutation Test($foo: String!) { + test(foo: $foo) + } + `, + }, + ]; + const schema = buildASTSchema(gql` + type Mutation { + test(foo: String!): Boolean + } + `); + const content = await plugin( + schema, + docs, + {}, + { + outputFile: 'graphql.tsx', + } + ); + + expect(content).toBeSimilarStringTo(` + export const TestComponent = (props: Omit & { variables?: TestMutationVariables }) => ( + + );`); + await validateTypeScript(content, schema, docs, {}); + }); + + it('should not add typesPrefix to Component', async () => { + const docs = [{ filePath: '', content: basicDoc }]; + const content = await plugin( + schema, + docs, + { typesPrefix: 'I' }, + { + outputFile: 'graphql.tsx', + } + ); + + expect(content).not.toContain(`export class ITestComponent`); + }); + }); + + describe('Hooks', () => { + it('Should generate hooks for query and mutation', async () => { + const documents = parse(/* GraphQL */ ` + query feed { + feed { + id + commentCount + repository { + full_name + html_url + owner { + avatar_url + } + } + } + } + + mutation submitRepository($name: String) { + submitRepository(repoFullName: $name) { + id + } + } + `); + const docs = [{ filePath: '', content: documents }]; + + const content = await plugin( + schema, + docs, + { withHooks: true, withComponent: false }, + { + outputFile: 'graphql.tsx', + } + ); + + expect(content).toBeSimilarStringTo(` +export function useFeedQuery(options: Urql.UseQueryArgs = {}) { + return Urql.useQuery({ query: FeedDocument, ...options }); +};`); + + expect(content).toBeSimilarStringTo(` +export function useSubmitRepositoryMutation() { + return Urql.useMutation(SubmitRepositoryDocument); +};`); + await validateTypeScript(content, schema, docs, {}); + }); + + it('Should not generate hooks for query and mutation', async () => { + const docs = [{ filePath: '', content: basicDoc }]; + const content = await plugin( + schema, + docs, + { withHooks: false }, + { + outputFile: 'graphql.tsx', + } + ); + + expect(content).not.toContain(`export function useTestQuery`); + await validateTypeScript(content, schema, docs, {}); + }); + + it('Should generate subscription hooks', async () => { + const documents = parse(/* GraphQL */ ` + subscription ListenToComments($name: String) { + commentAdded(repoFullName: $name) { + id + } + } + `); + + const docs = [{ filePath: '', content: documents }]; + + const content = await plugin( + schema, + docs, + { + withHooks: true, + withComponent: false, + }, + { + outputFile: 'graphql.tsx', + } + ); + + expect(content).toBeSimilarStringTo(` +export function useListenToCommentsSubscription(options: Urql.UseSubscriptionArgs = {}) { + return Urql.useSubscription({ query: ListenToCommentsDocument, ...options }); +};`); + await validateTypeScript(content, schema, docs, {}); + }); + + it('Should not add typesPrefix to hooks', async () => { + const docs = [{ filePath: '', content: basicDoc }]; + const content = await plugin( + schema, + docs, + { withHooks: true, typesPrefix: 'I' }, + { + outputFile: 'graphql.tsx', + } + ); + + expect(content).toContain(`export function useTestQuery`); + }); + }); +}); diff --git a/packages/plugins/typescript/urql/tsconfig.json b/packages/plugins/typescript/urql/tsconfig.json new file mode 100644 index 00000000000..42d8ecdd098 --- /dev/null +++ b/packages/plugins/typescript/urql/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "importHelpers": true, + "experimentalDecorators": true, + "module": "esnext", + "target": "es2018", + "lib": ["es6", "esnext", "es2015"], + "suppressImplicitAnyIndexErrors": true, + "moduleResolution": "node", + "emitDecoratorMetadata": true, + "sourceMap": true, + "declaration": true, + "outDir": "./dist/", + "noImplicitAny": false, + "noImplicitThis": true, + "alwaysStrict": true, + "noImplicitReturns": true, + "noUnusedLocals": false, + "noUnusedParameters": false + }, + "exclude": ["node_modules", "tests", "dist", "example"] +}