diff --git a/.changeset/@graphql-codegen_cli-8302-dependencies.md b/.changeset/@graphql-codegen_cli-8302-dependencies.md new file mode 100644 index 00000000000..27eb87962f5 --- /dev/null +++ b/.changeset/@graphql-codegen_cli-8302-dependencies.md @@ -0,0 +1,11 @@ +--- +"@graphql-codegen/cli": patch +--- +dependencies updates: + - Updated dependency [`@graphql-codegen/plugin-helpers@^2.6.2` ↗︎](https://www.npmjs.com/package/@graphql-codegen/plugin-helpers/v/2.6.2) (from `^2.7.1`, in `dependencies`) + - Updated dependency [`@whatwg-node/fetch@^0.3.0` ↗︎](https://www.npmjs.com/package/@whatwg-node/fetch/v/0.3.0) (from `^0.4.0`, in `dependencies`) + - Updated dependency [`cosmiconfig-typescript-loader@4.0.0` ↗︎](https://www.npmjs.com/package/cosmiconfig-typescript-loader/v/4.0.0) (from `^4.0.0`, in `dependencies`) + - Added dependency [`@babel/generator@^7.18.13` ↗︎](https://www.npmjs.com/package/@babel/generator/v/7.18.13) (to `dependencies`) + - Added dependency [`@babel/template@^7.18.10` ↗︎](https://www.npmjs.com/package/@babel/template/v/7.18.10) (to `dependencies`) + - Added dependency [`@babel/types@^7.18.13` ↗︎](https://www.npmjs.com/package/@babel/types/v/7.18.13) (to `dependencies`) + - Added dependency [`@graphql-codegen/client-preset@1.0.1-alpha-20220823170145-c93d8aee3` ↗︎](https://www.npmjs.com/package/@graphql-codegen/client-preset/v/1.0.1) (to `dependencies`) diff --git a/.changeset/orange-hornets-thank.md b/.changeset/orange-hornets-thank.md new file mode 100644 index 00000000000..0d3a7fc4525 --- /dev/null +++ b/.changeset/orange-hornets-thank.md @@ -0,0 +1,23 @@ +--- +'@graphql-codegen/gql-tag-operations': minor +'@graphql-codegen/gql-tag-operations-preset': minor +"@graphql-codegen/cli": minor +'@graphql-codegen/client-preset': patch +--- + +**`@graphql-codegen/gql-tag-operations` and `@graphql-codegen/gql-tag-operations-preset`** + +Introduce a `gqlTagName` configuration option + +----- + +**`@graphql-codegen/client-preset`** + +New preset for GraphQL Code Generator v3, more information on the RFC: https://github.com/dotansimha/graphql-code-generator/issues/8296 + + +----- + +**`@graphql-codegen/cli`** + +Update init wizard with 3.0 recommendations (`codegen.ts`, `client` preset) diff --git a/dev-test/codegen.yml b/dev-test/codegen.yml index 7fb38f92c94..410ead468d1 100644 --- a/dev-test/codegen.yml +++ b/dev-test/codegen.yml @@ -422,6 +422,10 @@ generates: schema: ./dev-test/gql-tag-operations/schema.graphql documents: './dev-test/gql-tag-operations/src/**/*.ts' preset: gql-tag-operations-preset + ./dev-test/gql-tag-operations/graphql: + schema: ./dev-test/gql-tag-operations/schema.graphql + documents: './dev-test/gql-tag-operations/src/**/*.ts' + preset: client ./dev-test/gql-tag-operations-urql/gql: schema: ./dev-test/gql-tag-operations-urql/schema.graphql documents: './dev-test/gql-tag-operations-urql/src/**/*.ts' diff --git a/dev-test/gql-tag-operations-masking-star-wars/gql/gql.ts b/dev-test/gql-tag-operations-masking-star-wars/gql/gql.ts index e3185cda063..47845d28f78 100644 --- a/dev-test/gql-tag-operations-masking-star-wars/gql/gql.ts +++ b/dev-test/gql-tag-operations-masking-star-wars/gql/gql.ts @@ -1,12 +1,12 @@ /* eslint-disable */ -import * as graphql from './graphql.js'; +import * as types from './graphql.js'; import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; const documents = { '\n query HeroDetailsWithFragment($episode: Episode) {\n hero(episode: $episode) {\n ...HeroDetails\n }\n }\n': - graphql.HeroDetailsWithFragmentDocument, + types.HeroDetailsWithFragmentDocument, '\n fragment HeroDetails on Character {\n __typename\n name\n ... on Human {\n height\n }\n ... on Droid {\n primaryFunction\n }\n }\n': - graphql.HeroDetailsFragmentDoc, + types.HeroDetailsFragmentDoc, }; export function gql( diff --git a/dev-test/gql-tag-operations-masking/gql/gql.ts b/dev-test/gql-tag-operations-masking/gql/gql.ts index bd7779363d1..512c16fee0d 100644 --- a/dev-test/gql-tag-operations-masking/gql/gql.ts +++ b/dev-test/gql-tag-operations-masking/gql/gql.ts @@ -1,15 +1,15 @@ /* eslint-disable */ -import * as graphql from './graphql.js'; +import * as types from './graphql.js'; import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; const documents = { '\n fragment TweetFragment on Tweet {\n id\n body\n ...TweetAuthorFragment\n }\n': - graphql.TweetFragmentFragmentDoc, + types.TweetFragmentFragmentDoc, '\n fragment TweetAuthorFragment on Tweet {\n id\n author {\n id\n username\n }\n }\n': - graphql.TweetAuthorFragmentFragmentDoc, + types.TweetAuthorFragmentFragmentDoc, '\n fragment TweetsFragment on Query {\n Tweets {\n id\n ...TweetFragment\n }\n }\n': - graphql.TweetsFragmentFragmentDoc, - '\n query TweetAppQuery {\n ...TweetsFragment\n }\n': graphql.TweetAppQueryDocument, + types.TweetsFragmentFragmentDoc, + '\n query TweetAppQuery {\n ...TweetsFragment\n }\n': types.TweetAppQueryDocument, }; export function gql( diff --git a/dev-test/gql-tag-operations/gql/gql.ts b/dev-test/gql-tag-operations/gql/gql.ts index 9557babbbb9..6a5d104ee3c 100644 --- a/dev-test/gql-tag-operations/gql/gql.ts +++ b/dev-test/gql-tag-operations/gql/gql.ts @@ -1,11 +1,11 @@ /* eslint-disable */ -import * as graphql from './graphql.js'; +import * as types from './graphql.js'; import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; const documents = { - '\n query Foo {\n Tweets {\n id\n }\n }\n': graphql.FooDocument, - '\n fragment Lel on Tweet {\n id\n body\n }\n': graphql.LelFragmentDoc, - '\n query Bar {\n Tweets {\n ...Lel\n }\n }\n': graphql.BarDocument, + '\n query Foo {\n Tweets {\n id\n }\n }\n': types.FooDocument, + '\n fragment Lel on Tweet {\n id\n body\n }\n': types.LelFragmentDoc, + '\n query Bar {\n Tweets {\n ...Lel\n }\n }\n': types.BarDocument, }; export function gql( diff --git a/dev-test/gql-tag-operations/graphql/fragment-masking.ts b/dev-test/gql-tag-operations/graphql/fragment-masking.ts new file mode 100644 index 00000000000..a939c1d1cc2 --- /dev/null +++ b/dev-test/gql-tag-operations/graphql/fragment-masking.ts @@ -0,0 +1,43 @@ +import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; + +export type FragmentType> = TDocumentType extends DocumentNode< + infer TType, + any +> + ? TType extends { ' $fragmentName': infer TKey } + ? TKey extends string + ? { ' $fragmentRefs': { [key in TKey]: TType } } + : never + : never + : never; + +// return non-nullable if `fragmentType` is non-nullable +export function useFragment( + _documentNode: DocumentNode, + fragmentType: FragmentType> +): TType; +// return nullable if `fragmentType` is nullable +export function useFragment( + _documentNode: DocumentNode, + fragmentType: FragmentType> | null | undefined +): TType | null | undefined; +// return array of non-nullable if `fragmentType` is array of non-nullable +export function useFragment( + _documentNode: DocumentNode, + fragmentType: ReadonlyArray>> +): ReadonlyArray; +// return array of nullable if `fragmentType` is array of nullable +export function useFragment( + _documentNode: DocumentNode, + fragmentType: ReadonlyArray>> | null | undefined +): ReadonlyArray | null | undefined; +export function useFragment( + _documentNode: DocumentNode, + fragmentType: + | FragmentType> + | ReadonlyArray>> + | null + | undefined +): TType | ReadonlyArray | null | undefined { + return fragmentType as any; +} diff --git a/dev-test/gql-tag-operations/graphql/gql.ts b/dev-test/gql-tag-operations/graphql/gql.ts new file mode 100644 index 00000000000..3b2f2cf24a6 --- /dev/null +++ b/dev-test/gql-tag-operations/graphql/gql.ts @@ -0,0 +1,31 @@ +/* eslint-disable */ +import * as types from './graphql.js'; +import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; + +const documents = { + '\n query Foo {\n Tweets {\n id\n }\n }\n': types.FooDocument, + '\n fragment Lel on Tweet {\n id\n body\n }\n': types.LelFragmentDoc, + '\n query Bar {\n Tweets {\n ...Lel\n }\n }\n': types.BarDocument, +}; + +export function graphql( + source: '\n query Foo {\n Tweets {\n id\n }\n }\n' +): typeof documents['\n query Foo {\n Tweets {\n id\n }\n }\n']; +export function graphql( + source: '\n fragment Lel on Tweet {\n id\n body\n }\n' +): typeof documents['\n fragment Lel on Tweet {\n id\n body\n }\n']; +export function graphql( + source: '\n query Bar {\n Tweets {\n ...Lel\n }\n }\n' +): typeof documents['\n query Bar {\n Tweets {\n ...Lel\n }\n }\n']; + +export function graphql(source: string): unknown; +export function graphql(source: string) { + return (documents as any)[source] ?? {}; +} + +export type DocumentType> = TDocumentNode extends DocumentNode< + infer TType, + any +> + ? TType + : never; diff --git a/dev-test/gql-tag-operations/graphql/graphql.ts b/dev-test/gql-tag-operations/graphql/graphql.ts new file mode 100644 index 00000000000..aff65bfbfaf --- /dev/null +++ b/dev-test/gql-tag-operations/graphql/graphql.ts @@ -0,0 +1,186 @@ +/* eslint-disable */ +import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; +export type Maybe = T | null; +export type InputMaybe = Maybe; +export type Exact = { [K in keyof T]: T[K] }; +export type MakeOptional = Omit & { [SubKey in K]?: Maybe }; +export type MakeMaybe = Omit & { [SubKey in K]: Maybe }; +/** All built-in and custom scalars, mapped to their actual values */ +export type Scalars = { + ID: string; + String: string; + Boolean: boolean; + Int: number; + Float: number; + Date: any; + Url: any; +}; + +export type Meta = { + __typename?: 'Meta'; + count?: Maybe; +}; + +export type Mutation = { + __typename?: 'Mutation'; + createTweet?: Maybe; + deleteTweet?: Maybe; + markTweetRead?: Maybe; +}; + +export type MutationCreateTweetArgs = { + body?: InputMaybe; +}; + +export type MutationDeleteTweetArgs = { + id: Scalars['ID']; +}; + +export type MutationMarkTweetReadArgs = { + id: Scalars['ID']; +}; + +export type Notification = { + __typename?: 'Notification'; + date?: Maybe; + id?: Maybe; + type?: Maybe; +}; + +export type Query = { + __typename?: 'Query'; + Notifications?: Maybe>>; + NotificationsMeta?: Maybe; + Tweet?: Maybe; + Tweets?: Maybe>>; + TweetsMeta?: Maybe; + User?: Maybe; +}; + +export type QueryNotificationsArgs = { + limit?: InputMaybe; +}; + +export type QueryTweetArgs = { + id: Scalars['ID']; +}; + +export type QueryTweetsArgs = { + limit?: InputMaybe; + skip?: InputMaybe; + sort_field?: InputMaybe; + sort_order?: InputMaybe; +}; + +export type QueryUserArgs = { + id: Scalars['ID']; +}; + +export type Stat = { + __typename?: 'Stat'; + likes?: Maybe; + responses?: Maybe; + retweets?: Maybe; + views?: Maybe; +}; + +export type Tweet = { + __typename?: 'Tweet'; + Author?: Maybe; + Stats?: Maybe; + body?: Maybe; + date?: Maybe; + id: Scalars['ID']; +}; + +export type User = { + __typename?: 'User'; + avatar_url?: Maybe; + first_name?: Maybe; + full_name?: Maybe; + id: Scalars['ID']; + last_name?: Maybe; + /** @deprecated Field no longer supported */ + name?: Maybe; + username?: Maybe; +}; + +export type FooQueryVariables = Exact<{ [key: string]: never }>; + +export type FooQuery = { __typename?: 'Query'; Tweets?: Array<{ __typename?: 'Tweet'; id: string } | null> | null }; + +export type LelFragment = { __typename?: 'Tweet'; id: string; body?: string | null } & { + ' $fragmentName': 'LelFragment'; +}; + +export type BarQueryVariables = Exact<{ [key: string]: never }>; + +export type BarQuery = { + __typename?: 'Query'; + Tweets?: Array<({ __typename?: 'Tweet' } & { ' $fragmentRefs': { LelFragment: LelFragment } }) | null> | null; +}; + +export const LelFragmentDoc = { + kind: 'Document', + definitions: [ + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'Lel' }, + typeCondition: { kind: 'NamedType', name: { kind: 'Name', value: 'Tweet' } }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'body' } }, + ], + }, + }, + ], +} as unknown as DocumentNode; +export const FooDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: { kind: 'Name', value: 'Foo' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'Tweets' }, + selectionSet: { + kind: 'SelectionSet', + selections: [{ kind: 'Field', name: { kind: 'Name', value: 'id' } }], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode; +export const BarDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: { kind: 'Name', value: 'Bar' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'Tweets' }, + selectionSet: { + kind: 'SelectionSet', + selections: [{ kind: 'FragmentSpread', name: { kind: 'Name', value: 'Lel' } }], + }, + }, + ], + }, + }, + ...LelFragmentDoc.definitions, + ], +} as unknown as DocumentNode; diff --git a/dev-test/gql-tag-operations/graphql/index.ts b/dev-test/gql-tag-operations/graphql/index.ts new file mode 100644 index 00000000000..0fa85dc2148 --- /dev/null +++ b/dev-test/gql-tag-operations/graphql/index.ts @@ -0,0 +1,2 @@ +export * from './gql.js'; +export * from './fragment-masking.js'; diff --git a/packages/graphql-codegen-cli/package.json b/packages/graphql-codegen-cli/package.json index dd4aebe57de..182e638be0e 100644 --- a/packages/graphql-codegen-cli/package.json +++ b/packages/graphql-codegen-cli/package.json @@ -40,8 +40,12 @@ }, "homepage": "https://github.com/dotansimha/graphql-code-generator#readme", "dependencies": { + "@babel/generator": "^7.18.13", + "@babel/template": "^7.18.10", + "@babel/types": "^7.18.13", + "@graphql-codegen/client-preset": "1.0.1-alpha-20220823170145-c93d8aee3", "@graphql-codegen/core": "2.6.2", - "@graphql-codegen/plugin-helpers": "^2.7.1", + "@graphql-codegen/plugin-helpers": "^2.6.2", "@graphql-tools/apollo-engine-loader": "^7.3.6", "@graphql-tools/code-file-loader": "^7.3.1", "@graphql-tools/git-loader": "^7.2.1", @@ -52,12 +56,12 @@ "@graphql-tools/prisma-loader": "^7.2.7", "@graphql-tools/url-loader": "^7.13.2", "@graphql-tools/utils": "^8.9.0", - "@whatwg-node/fetch": "^0.4.0", + "@whatwg-node/fetch": "^0.3.0", "ansi-escapes": "^4.3.1", "chalk": "^4.1.0", "chokidar": "^3.5.2", "cosmiconfig": "^7.0.0", - "cosmiconfig-typescript-loader": "^4.0.0", + "cosmiconfig-typescript-loader": "4.0.0", "debounce": "^1.2.0", "detect-indent": "^6.0.0", "graphql-config": "^4.3.5", @@ -74,7 +78,7 @@ "yargs": "^17.0.0" }, "devDependencies": { - "@graphql-tools/merge": "8.3.5", + "@graphql-tools/merge": "8.3.4", "@types/debounce": "1.2.1", "@types/inquirer": "8.2.3", "@types/is-glob": "4.0.2", diff --git a/packages/graphql-codegen-cli/src/init/helpers.ts b/packages/graphql-codegen-cli/src/init/helpers.ts index c4937edd3d0..8c341a5f9fa 100644 --- a/packages/graphql-codegen-cli/src/init/helpers.ts +++ b/packages/graphql-codegen-cli/src/init/helpers.ts @@ -3,17 +3,63 @@ import { resolve, relative } from 'path'; import { writeFileSync, readFileSync } from 'fs'; import { Types } from '@graphql-codegen/plugin-helpers'; import detectIndent from 'detect-indent'; -import { Answers } from './types.js'; +import { Answers, Tags } from './types.js'; import { getLatestVersion } from '../utils/get-latest-version.js'; +import template from '@babel/template'; +import generate from '@babel/generator'; +import * as t from '@babel/types'; + +function jsObjectToBabelObjectExpression(obj: T): ReturnType { + const objExp = t.objectExpression([]); + + Object.entries(obj).forEach(([key, val]) => { + if (Array.isArray(val)) { + objExp.properties.push( + t.objectProperty( + /^[a-zA-Z0-9]+$/.test(key) ? t.identifier(key) : t.stringLiteral(key), + t.arrayExpression( + val.map(v => (typeof v === 'object' ? jsObjectToBabelObjectExpression(v as object) : t.valueToNode(v))) + ) + ) + ); + } else { + objExp.properties.push( + t.objectProperty( + /^[a-zA-Z0-9]+$/.test(key) ? t.identifier(key) : t.stringLiteral(key), + typeof val === 'object' ? jsObjectToBabelObjectExpression(val as unknown as object) : t.valueToNode(val) + ) + ); + } + }); + + return objExp; +} // Parses config and writes it to a file export async function writeConfig(answers: Answers, config: Types.Config) { const YAML = await import('json-to-pretty-yaml').then(m => ('default' in m ? m.default : m)); - const ext = answers.config.toLocaleLowerCase().endsWith('.json') ? 'json' : 'yml'; - const content = ext === 'json' ? JSON.stringify(config) : YAML.stringify(config); + const ext = answers.config.toLocaleLowerCase().split('.')[1]; const fullPath = resolve(process.cwd(), answers.config); const relativePath = relative(process.cwd(), answers.config); + let content: string; + + if (ext === 'ts') { + const buildRequire = template.statement(`%%config%%`); + const ast = buildRequire({ + config: jsObjectToBabelObjectExpression(config), + }); + + content = ` +import type { CodegenConfig } from '@graphql-codegen/cli'; + +const config: CodegenConfig = ${generate(ast).code.replace(/\(|\)/g, '')} + +export default config; +`; + } else { + content = ext === 'json' ? JSON.stringify(config) : YAML.stringify(config); + } writeFileSync(fullPath, content, { encoding: 'utf-8', }); @@ -46,7 +92,7 @@ export async function writePackage(answers: Answers, configLocation: string) { } await Promise.all( - answers.plugins.map(async plugin => { + (answers.plugins || []).map(async plugin => { pkg.devDependencies[plugin.package] = await getLatestVersion(plugin.package); }) ); @@ -56,6 +102,9 @@ export async function writePackage(answers: Answers, configLocation: string) { } pkg.devDependencies['@graphql-codegen/cli'] = await getLatestVersion('@graphql-codegen/cli'); + if (answers.targets.includes(Tags.client)) { + pkg.devDependencies['@graphql-codegen/client'] = await getLatestVersion('@graphql-codegen/client'); + } writeFileSync(pkgPath, JSON.stringify(pkg, null, indent)); } diff --git a/packages/graphql-codegen-cli/src/init/index.ts b/packages/graphql-codegen-cli/src/init/index.ts index d099bb75d0a..bb80b713ea0 100644 --- a/packages/graphql-codegen-cli/src/init/index.ts +++ b/packages/graphql-codegen-cli/src/init/index.ts @@ -24,10 +24,15 @@ export async function init() { const config: Types.Config = { overwrite: true, schema: answers.schema, - documents: answers.targets.includes(Tags.browser) ? answers.documents : null, + ...(answers.targets.includes(Tags.client) || + answers.targets.includes(Tags.angular) || + answers.targets.includes(Tags.stencil) + ? { documents: answers.documents } + : {}), generates: { [answers.output]: { - plugins: answers.plugins.map(p => p.value), + ...(answers.targets.includes(Tags.client) ? { preset: 'client' } : {}), + plugins: answers.plugins ? answers.plugins.map(p => p.value) : [], }, }, }; @@ -48,7 +53,7 @@ export async function init() { // Emit status to the terminal log(` Config file generated at ${bold(relativePath)} - + ${bold('$ npm install')} To install the plugins. diff --git a/packages/graphql-codegen-cli/src/init/plugins.ts b/packages/graphql-codegen-cli/src/init/plugins.ts index 1583515108a..2bd07271a04 100644 --- a/packages/graphql-codegen-cli/src/init/plugins.ts +++ b/packages/graphql-codegen-cli/src/init/plugins.ts @@ -17,7 +17,7 @@ export const plugins: Array = [ package: '@graphql-codegen/typescript-operations', value: 'typescript-operations', pathInRepo: 'typescript/operations', - available: tags => allOf(tags, Tags.browser, Tags.typescript), + available: tags => allOf(tags, Tags.client, Tags.typescript) || hasTag(Tags.stencil)(tags), shouldBeSelected: tags => oneOf(tags, Tags.angular, Tags.stencil) || allOf(tags, Tags.typescript, Tags.react), defaultExtension: '.ts', }, @@ -44,7 +44,7 @@ export const plugins: Array = [ package: '@graphql-codegen/flow-operations', value: 'flow-operations', pathInRepo: 'flow/operations', - available: tags => allOf(tags, Tags.browser, Tags.flow), + available: tags => allOf(tags, Tags.client, Tags.flow), shouldBeSelected: tags => noneOf(tags, Tags.typescript), defaultExtension: '.js', }, @@ -57,42 +57,6 @@ export const plugins: Array = [ shouldBeSelected: tags => noneOf(tags, Tags.typescript), defaultExtension: '.js', }, - { - name: `TypeScript Apollo Angular ${italic('(typed GQL services)')}`, - package: '@graphql-codegen/typescript-apollo-angular', - value: 'typescript-apollo-angular', - pathInRepo: 'typescript/apollo-angular', - available: hasTag(Tags.angular), - shouldBeSelected: () => true, - defaultExtension: '.js', - }, - { - name: `TypeScript Vue Apollo Composition API ${italic('(typed functions)')}`, - package: '@graphql-codegen/typescript-vue-apollo', - value: 'typescript-vue-apollo', - pathInRepo: 'typescript/vue-apollo', - available: tags => allOf(tags, Tags.vue, Tags.typescript), - shouldBeSelected: () => true, - defaultExtension: '.ts', - }, - { - name: `TypeScript Vue Apollo Smart Operations ${italic('(typed functions)')}`, - package: '@graphql-codegen/typescript-vue-apollo-smart-ops', - value: 'typescript-vue-apollo-smart-ops', - pathInRepo: 'typescript/vue-apollo-smart-ops', - available: tags => allOf(tags, Tags.vue, Tags.typescript), - shouldBeSelected: () => true, - defaultExtension: '.ts', - }, - { - name: `TypeScript React Apollo ${italic('(typed components and HOCs)')}`, - package: '@graphql-codegen/typescript-react-apollo', - value: 'typescript-react-apollo', - pathInRepo: 'typescript/react-apollo', - available: tags => allOf(tags, Tags.react, Tags.typescript), - shouldBeSelected: () => true, - defaultExtension: '.tsx', - }, { name: `TypeScript Stencil Apollo ${italic('(typed components)')}`, package: '@graphql-codegen/typescript-stencil-apollo', @@ -116,7 +80,7 @@ export const plugins: Array = [ package: '@graphql-codegen/typescript-graphql-files-modules', value: 'typescript-graphql-files-modules', pathInRepo: 'typescript/graphql-files-modules', - available: tags => allOf(tags, Tags.browser, Tags.typescript), + available: tags => allOf(tags, Tags.client, Tags.typescript) || hasTag(Tags.stencil)(tags), shouldBeSelected: () => false, defaultExtension: '.ts', }, @@ -125,7 +89,7 @@ export const plugins: Array = [ package: '@graphql-codegen/typescript-document-nodes', value: 'typescript-document-nodes', pathInRepo: 'typescript/document-nodes', - available: tags => allOf(tags, Tags.typescript), + available: tags => allOf(tags, Tags.typescript) || hasTag(Tags.stencil)(tags), shouldBeSelected: () => false, defaultExtension: '.ts', }, @@ -134,7 +98,7 @@ export const plugins: Array = [ package: '@graphql-codegen/fragment-matcher', value: 'fragment-matcher', pathInRepo: 'other/fragment-matcher', - available: hasTag(Tags.browser), + available: tags => hasTag(Tags.client)(tags) || hasTag(Tags.angular)(tags) || hasTag(Tags.stencil)(tags), shouldBeSelected: () => false, defaultExtension: '.ts', }, @@ -143,10 +107,19 @@ export const plugins: Array = [ package: '@graphql-codegen/urql-introspection', value: 'urql-introspection', pathInRepo: 'other/urql-introspection', - available: hasTag(Tags.browser), + available: tags => hasTag(Tags.client)(tags) || hasTag(Tags.stencil)(tags), shouldBeSelected: () => false, defaultExtension: '.ts', }, + { + name: `TypeScript Apollo Angular ${italic('(typed GQL services)')}`, + package: '@graphql-codegen/typescript-apollo-angular', + value: 'typescript-apollo-angular', + pathInRepo: 'typescript/apollo-angular', + available: hasTag(Tags.angular), + shouldBeSelected: () => true, + defaultExtension: '.ts', + }, ]; function hasTag(tag: Tags) { diff --git a/packages/graphql-codegen-cli/src/init/questions.ts b/packages/graphql-codegen-cli/src/init/questions.ts index a740aa081d3..b5dc18cc8d7 100644 --- a/packages/graphql-codegen-cli/src/init/questions.ts +++ b/packages/graphql-codegen-cli/src/init/questions.ts @@ -6,11 +6,12 @@ import { plugins } from './plugins.js'; export function getQuestions(possibleTargets: Record): inquirer.QuestionCollection { return [ { - type: 'checkbox', + type: 'list', name: 'targets', message: `What type of application are you building?`, choices: getApplicationTypeChoices(possibleTargets), validate: ((targets: any[]) => targets.length > 0) as any, + default: getApplicationTypeChoices(possibleTargets).findIndex(c => c.checked), }, { type: 'input', @@ -29,14 +30,25 @@ export function getQuestions(possibleTargets: Record): inquirer.Q // I can't find an API in Inquirer that would do that answers.targets = normalizeTargets(answers.targets); - return answers.targets.includes(Tags.browser); + return ( + answers.targets.includes(Tags.client) || + answers.targets.includes(Tags.angular) || + answers.targets.includes(Tags.stencil) + ); }, - default: 'src/**/*.graphql', + default: getDocumentsDefaultValue, validate: (str: string) => str.length > 0, }, { type: 'checkbox', name: 'plugins', + when: answers => { + // flatten targets + // I can't find an API in Inquirer that would do that + answers.targets = normalizeTargets(answers.targets); + + return !answers.targets.includes(Tags.client); + }, message: 'Pick plugins:', choices: getPluginChoices, validate: ((plugins: any[]) => plugins.length > 0) as any, @@ -51,16 +63,20 @@ export function getQuestions(possibleTargets: Record): inquirer.Q { type: 'confirm', name: 'introspection', + default: false, message: 'Do you want to generate an introspection file?', }, { type: 'input', name: 'config', message: 'How to name the config file?', - default: 'codegen.yml', + default: answers => + answers.targets.includes(Tags.client) || answers.targets.includes(Tags.angular) ? 'codegen.ts' : 'codegen.yml', validate: (str: string) => { const isNotEmpty = str.length > 0; - const hasCorrectExtension = ['json', 'yml', 'yaml'].some(ext => str.toLocaleLowerCase().endsWith(`.${ext}`)); + const hasCorrectExtension = ['json', 'yml', 'yaml', 'js', 'ts'].some(ext => + str.toLocaleLowerCase().endsWith(`.${ext}`) + ); return isNotEmpty && hasCorrectExtension; }, @@ -68,6 +84,7 @@ export function getQuestions(possibleTargets: Record): inquirer.Q { type: 'input', name: 'script', + default: 'codegen', message: 'What script in package.json should run the codegen?', validate: (str: string) => str.length > 0, }, @@ -80,10 +97,10 @@ export function getApplicationTypeChoices(possibleTargets: Record tags.push(Tags.typescript); } else if (possibleTargets.Flow) { tags.push(Tags.flow); - } else { - tags.push(Tags.flow, Tags.typescript); + } else if (possibleTargets.Node) { + tags.push(Tags.typescript); + tags.push(Tags.flow); } - return tags; } @@ -97,25 +114,37 @@ export function getApplicationTypeChoices(possibleTargets: Record { name: 'Application built with Angular', key: 'angular', - value: [Tags.angular, Tags.browser, Tags.typescript], + value: [Tags.angular], checked: possibleTargets.Angular, }, { name: 'Application built with React', key: 'react', - value: withFlowOrTypescript([Tags.react, Tags.browser]), + value: withFlowOrTypescript([Tags.react, Tags.client]), checked: possibleTargets.React, }, { name: 'Application built with Stencil', key: 'stencil', - value: [Tags.stencil, Tags.browser, Tags.typescript], + value: [Tags.stencil, Tags.typescript], checked: possibleTargets.Stencil, }, + { + name: 'Application built with Vue', + key: 'vue', + value: [Tags.vue, Tags.client], + checked: possibleTargets.Vue, + }, + { + name: 'Application using graphql-request', + key: 'graphqlRequest', + value: [Tags.graphqlRequest, Tags.client], + checked: possibleTargets.graphqlRequest, + }, { name: 'Application built with other framework or vanilla JS', key: 'client', - value: [Tags.browser, Tags.typescript, Tags.flow], + value: [Tags.typescript, Tags.flow], checked: possibleTargets.Browser && !possibleTargets.Angular && !possibleTargets.React && !possibleTargets.Stencil, }, @@ -139,6 +168,9 @@ function normalizeTargets(targets: Tags[] | Tags[][]): Tags[] { } export function getOutputDefaultValue(answers: Answers) { + if (answers.targets.includes(Tags.client)) { + return 'src/gql'; + } if (answers.plugins.some(plugin => plugin.defaultExtension === '.tsx')) { return 'src/generated/graphql.tsx'; } @@ -147,3 +179,15 @@ export function getOutputDefaultValue(answers: Answers) { } return 'src/generated/graphql.js'; } + +export function getDocumentsDefaultValue(answers: Answers) { + if (answers.targets.includes(Tags.vue)) { + return 'src/**/*.vue'; + } else if (answers.targets.includes(Tags.angular)) { + return 'src/**/*.ts'; + } else if (answers.targets.includes(Tags.client)) { + return 'src/**/*.tsx'; + } else { + return 'src/**/*.graphql'; + } +} diff --git a/packages/graphql-codegen-cli/src/init/targets.ts b/packages/graphql-codegen-cli/src/init/targets.ts index 652ba133cf2..ecf109fb317 100644 --- a/packages/graphql-codegen-cli/src/init/targets.ts +++ b/packages/graphql-codegen-cli/src/init/targets.ts @@ -18,10 +18,11 @@ export async function guessTargets(): Promise> { [Tags.react]: isReact(dependencies), [Tags.stencil]: isStencil(dependencies), [Tags.vue]: isVue(dependencies), - [Tags.browser]: false, + [Tags.client]: false, [Tags.node]: false, [Tags.typescript]: isTypescript(dependencies), [Tags.flow]: isFlow(dependencies), + [Tags.graphqlRequest]: isGraphqlRequest(dependencies), }; } @@ -48,3 +49,7 @@ function isTypescript(dependencies: string[]): boolean { function isFlow(dependencies: string[]): boolean { return dependencies.includes('flow'); } + +function isGraphqlRequest(dependencies: string[]): boolean { + return dependencies.includes('graphql-request'); +} diff --git a/packages/graphql-codegen-cli/src/init/types.ts b/packages/graphql-codegen-cli/src/init/types.ts index 405b748cd30..df0aad3b941 100644 --- a/packages/graphql-codegen-cli/src/init/types.ts +++ b/packages/graphql-codegen-cli/src/init/types.ts @@ -11,7 +11,7 @@ export interface PluginOption { export interface Answers { targets: Tags[]; config: string; - plugins: PluginOption[]; + plugins?: PluginOption[]; schema: string; documents?: string; output: string; @@ -20,7 +20,7 @@ export interface Answers { } export enum Tags { - browser = 'Browser', + client = 'Browser', node = 'Node', typescript = 'TypeScript', flow = 'Flow', @@ -28,4 +28,5 @@ export enum Tags { stencil = 'Stencil', react = 'React', vue = 'Vue', + graphqlRequest = 'graphqlRequest', } diff --git a/packages/graphql-codegen-cli/tests/__snapshots__/init.spec.ts.snap b/packages/graphql-codegen-cli/tests/__snapshots__/init.spec.ts.snap new file mode 100644 index 00000000000..1d2c3114e21 --- /dev/null +++ b/packages/graphql-codegen-cli/tests/__snapshots__/init.spec.ts.snap @@ -0,0 +1,97 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`init custom setup 1`] = ` +" +import type { CodegenConfig } from '@graphql-codegen/cli'; + +const config: CodegenConfig = { + overwrite: true, + schema: "http://localhost:4000", + documents: "graphql/*.ts", + generates: { + "src/gql": { + preset: "client", + plugins: [] + }, + "./graphql.schema.json": { + plugins: ["introspection"] + } + } +}; + +export default config; +" +`; + +exports[`init plugins suggestions for client-side setup should use angular related plugins when @angular/core is found 1`] = ` +" +import type { CodegenConfig } from '@graphql-codegen/cli'; + +const config: CodegenConfig = { + overwrite: true, + schema: "http://localhost:4000", + documents: "src/**/*.ts", + generates: { + "src/generated/graphql.ts": { + plugins: ["typescript-apollo-angular"] + } + } +}; + +export default config; +" +`; + +exports[`init plugins suggestions for client-side setup should use react related plugins when react is found 1`] = ` +" +import type { CodegenConfig } from '@graphql-codegen/cli'; + +const config: CodegenConfig = { + overwrite: true, + schema: "http://localhost:4000", + documents: "src/**/*.tsx", + generates: { + "src/gql": { + preset: "client", + plugins: [] + } + } +}; + +export default config; +" +`; + +exports[`init should have few default values 1`] = ` +" +import type { CodegenConfig } from '@graphql-codegen/cli'; + +const config: CodegenConfig = { + overwrite: true, + schema: "http://localhost:4000", + documents: "src/**/*.tsx", + generates: { + "src/gql": { + preset: "client", + plugins: [] + } + } +}; + +export default config; +" +`; + +exports[`init should have few default values 2`] = ` +"overwrite: true +schema: "./schema.ts" +documents: "graphql/**/*.graphql" +generates: + graphql/index.ts: + preset: "client" + plugins: [] + ./graphql.schema.json: + plugins: + - "introspection" +" +`; diff --git a/packages/graphql-codegen-cli/tests/init.spec.ts b/packages/graphql-codegen-cli/tests/init.spec.ts index 5ffef2b066a..0efc4e229be 100644 --- a/packages/graphql-codegen-cli/tests/init.spec.ts +++ b/packages/graphql-codegen-cli/tests/init.spec.ts @@ -50,6 +50,30 @@ const packageJson = { '@stencil/core': 'x.x.x', }, }), + withVue: JSON.stringify({ + version, + dependencies: { + vue: 'x.x.x', + }, + }), + withReactQuery: JSON.stringify({ + version, + dependencies: { + '@tanstack/react-query': 'x.x.x', + }, + }), + withSWR: JSON.stringify({ + version, + dependencies: { + swr: 'x.x.x', + }, + }), + withGraphqlRequest: JSON.stringify({ + version, + dependencies: { + 'graphql-request': 'x.x.x', + }, + }), }; describe('init', () => { @@ -63,225 +87,222 @@ describe('init', () => { jest.clearAllMocks(); }); - it('should guess angular projects', async () => { - require('fs').__setMockFiles(resolve(process.cwd(), 'package.json'), packageJson.withAngular); - const targets = await guessTargets(); - expect(targets.Angular).toEqual(true); - }); - - it('should guess typescript projects', async () => { - require('fs').__setMockFiles(resolve(process.cwd(), 'package.json'), packageJson.withTypescript); - const targets = await guessTargets(); - expect(targets.TypeScript).toEqual(true); - }); - - it('should guess react projects', async () => { - require('fs').__setMockFiles(resolve(process.cwd(), 'package.json'), packageJson.withReact); - const targets = await guessTargets(); - expect(targets.React).toEqual(true); - }); - - it('should guess stencil projects', async () => { - require('fs').__setMockFiles(resolve(process.cwd(), 'package.json'), packageJson.withStencil); - const targets = await guessTargets(); - expect(targets.Stencil).toEqual(true); - }); - - it('should guess flow projects', async () => { - require('fs').__setMockFiles(resolve(process.cwd(), 'package.json'), packageJson.withFlow); - const targets = await guessTargets(); - expect(targets.Flow).toEqual(true); - }); - - it('should use angular related plugins when @angular/core is found', async () => { - const fs = require('fs'); - fs.__setMockFiles(resolve(process.cwd(), 'package.json'), packageJson.withAngular); - // make sure we don't write stuff - const writeFileSpy = jest.spyOn(fs, 'writeFileSync').mockImplementation(); - // silent - jest.spyOn(console, 'log').mockImplementation(); - - useInputs({ - onTarget: [ENTER], // confirm angular target - onSchema: [ENTER], // use default - onDocuments: [ENTER], - onPlugins: [ENTER], // use selected packages - onOutput: [ENTER], // use default output path - onIntrospection: ['n', ENTER], // no introspection, - onConfig: [ENTER], // use default config path - onScript: ['graphql', ENTER], // use custom npm script + describe('guessTargets()', () => { + it('should guess angular projects', async () => { + require('fs').__setMockFiles(resolve(process.cwd(), 'package.json'), packageJson.withAngular); + const targets = await guessTargets(); + expect(targets.Angular).toEqual(true); }); - await init(); - - expect(writeFileSpy).toHaveBeenCalledTimes(2); - - const pkg = JSON.parse(writeFileSpy.mock.calls[1][1] as string); - const config = load(writeFileSpy.mock.calls[0][1] as string) as Record; - - // should use default output path - expect(config.generates['src/generated/graphql.ts']).toBeDefined(); - - const output: any = config.generates['src/generated/graphql.ts']; - expect(output.plugins).toContainEqual('typescript'); - expect(output.plugins).toContainEqual('typescript-operations'); - expect(output.plugins).toContainEqual('typescript-apollo-angular'); - expect(output.plugins).toHaveLength(3); - - // expected plugins - expect(pkg.devDependencies).toHaveProperty('@graphql-codegen/typescript'); - expect(pkg.devDependencies).toHaveProperty('@graphql-codegen/typescript-operations'); - expect(pkg.devDependencies).toHaveProperty('@graphql-codegen/typescript-apollo-angular'); - // should not have other plugins - expect(Object.keys(pkg.devDependencies)).toHaveLength(4); - }); - - it('should use react related plugins when react is found', async () => { - const fs = require('fs'); - fs.__setMockFiles(resolve(process.cwd(), 'package.json'), packageJson.withReact); - // make sure we don't write stuff - const writeFileSpy = jest.spyOn(fs, 'writeFileSync').mockImplementation(); - // silent - jest.spyOn(console, 'log').mockImplementation(); - - useInputs({ - onTarget: [ENTER], // confirm react target - onSchema: [ENTER], // use default - onDocuments: [ENTER], - onPlugins: [ENTER], // use selected packages - onOutput: [ENTER], // use default output path - onIntrospection: ['n', ENTER], // no introspection, - onConfig: [ENTER], // use default config path - onScript: ['graphql', ENTER], // use custom npm script + it('should guess typescript projects', async () => { + require('fs').__setMockFiles(resolve(process.cwd(), 'package.json'), packageJson.withTypescript); + const targets = await guessTargets(); + expect(targets.TypeScript).toEqual(true); }); - await init(); - - expect(writeFileSpy).toHaveBeenCalledTimes(2); + it('should guess react projects', async () => { + require('fs').__setMockFiles(resolve(process.cwd(), 'package.json'), packageJson.withReact); + const targets = await guessTargets(); + expect(targets.React).toEqual(true); + }); - const pkg = JSON.parse(writeFileSpy.mock.calls[1][1] as string); - const config = load(writeFileSpy.mock.calls[0][1] as string) as Record; + it('should guess stencil projects', async () => { + require('fs').__setMockFiles(resolve(process.cwd(), 'package.json'), packageJson.withStencil); + const targets = await guessTargets(); + expect(targets.Stencil).toEqual(true); + }); - // should use default output path - expect(config.generates['src/generated/graphql.tsx']).toBeDefined(); + it('should guess flow projects', async () => { + require('fs').__setMockFiles(resolve(process.cwd(), 'package.json'), packageJson.withFlow); + const targets = await guessTargets(); + expect(targets.Flow).toEqual(true); + }); - const output: any = config.generates['src/generated/graphql.tsx']; - expect(output.plugins).toContainEqual('typescript'); - expect(output.plugins).toContainEqual('typescript-operations'); - expect(output.plugins).toContainEqual('typescript-react-apollo'); - expect(output.plugins).toHaveLength(3); + it('should guess vue projects', async () => { + require('fs').__setMockFiles(resolve(process.cwd(), 'package.json'), packageJson.withVue); + const targets = await guessTargets(); + expect(targets.Vue).toEqual(true); + }); - // expected plugins - expect(pkg.devDependencies).toHaveProperty('@graphql-codegen/typescript'); - expect(pkg.devDependencies).toHaveProperty('@graphql-codegen/typescript-operations'); - expect(pkg.devDependencies).toHaveProperty('@graphql-codegen/typescript-react-apollo'); - // should not have other plugins - expect(Object.keys(pkg.devDependencies)).toHaveLength(4); + it('should guess graphql-request projects', async () => { + require('fs').__setMockFiles(resolve(process.cwd(), 'package.json'), packageJson.withGraphqlRequest); + const targets = await guessTargets(); + expect(targets.graphqlRequest).toEqual(true); + }); }); - it('should use stencil related plugins when @stencil/core is found', async () => { - const fs = require('fs'); - fs.__setMockFiles(resolve(process.cwd(), 'package.json'), packageJson.withStencil); - // make sure we don't write stuff - const writeFileSpy = jest.spyOn(fs, 'writeFileSync').mockImplementation(); - // silent - jest.spyOn(console, 'log').mockImplementation(); - - useInputs({ - onTarget: [ENTER], // confirm angular target - onSchema: [ENTER], // use default - onDocuments: [ENTER], - onPlugins: [ENTER], // use selected packages - onOutput: [ENTER], // use default output path - onIntrospection: ['n', ENTER], // no introspection, - onConfig: [ENTER], // use default config path - onScript: ['graphql', ENTER], // use custom npm script + describe('plugins suggestions for client-side setup', () => { + it('should use angular related plugins when @angular/core is found', async () => { + const fs = require('fs'); + fs.__setMockFiles(resolve(process.cwd(), 'package.json'), packageJson.withAngular); + // make sure we don't write stuff + const writeFileSpy = jest.spyOn(fs, 'writeFileSync').mockImplementation(); + // silent + jest.spyOn(console, 'log').mockImplementation(); + + useInputs({ + onTarget: [ENTER], // confirm target + onSchema: [ENTER], // use default + onDocuments: [ENTER], + onPlugins: [ENTER], // use selected packages + onOutput: [ENTER], // use default output path + onIntrospection: ['n', ENTER], // no introspection, + onConfig: [ENTER], // use default config path + onScript: ['graphql', ENTER], // use custom npm script + }); + + await init(); + + expect(writeFileSpy).toHaveBeenCalledTimes(2); + + const pkg = JSON.parse(writeFileSpy.mock.calls[1][1] as string); + const config = writeFileSpy.mock.calls[0][1] as string; + + expect(config).toMatchSnapshot(); + + // expected plugins + expect(pkg.devDependencies).toEqual({ + '@graphql-codegen/cli': '1.0.0', + '@graphql-codegen/typescript-apollo-angular': '1.0.0', + }); }); - await init(); - - expect(writeFileSpy).toHaveBeenCalledTimes(2); - - const pkg = JSON.parse(writeFileSpy.mock.calls[1][1] as string); - const config = load(writeFileSpy.mock.calls[0][1] as string) as Record; - - // should use default output path - expect(config.generates['src/generated/graphql.tsx']).toBeDefined(); - - const output: any = config.generates['src/generated/graphql.tsx']; - expect(output.plugins).toContainEqual('typescript'); - expect(output.plugins).toContainEqual('typescript-operations'); - expect(output.plugins).toContainEqual('typescript-stencil-apollo'); - expect(output.plugins).toHaveLength(3); + it('should use react related plugins when react is found', async () => { + const fs = require('fs'); + fs.__setMockFiles(resolve(process.cwd(), 'package.json'), packageJson.withReact); + // make sure we don't write stuff + const writeFileSpy = jest.spyOn(fs, 'writeFileSync').mockImplementation(); + // silent + jest.spyOn(console, 'log').mockImplementation(); + + useInputs({ + onTarget: [ENTER], // confirm react target + onSchema: [ENTER], // use default + onDocuments: [ENTER], + onOutput: [ENTER], // use default output path + onIntrospection: ['n', ENTER], // no introspection, + onConfig: [ENTER], // use default config path + onScript: ['graphql', ENTER], // use custom npm script + }); + + await init(); + + expect(writeFileSpy).toHaveBeenCalledTimes(2); + + const pkg = JSON.parse(writeFileSpy.mock.calls[1][1] as string); + const config = writeFileSpy.mock.calls[0][1] as string; + + expect(config).toMatchSnapshot(); + + // expected plugins + expect(pkg.devDependencies).toHaveProperty('@graphql-codegen/cli'); + expect(pkg.devDependencies).toHaveProperty('@graphql-codegen/client'); + // should not have other plugins + expect(Object.keys(pkg.devDependencies)).toHaveLength(2); + }); - // expected plugins - expect(pkg.devDependencies).toHaveProperty('@graphql-codegen/typescript'); - expect(pkg.devDependencies).toHaveProperty('@graphql-codegen/typescript-operations'); - expect(pkg.devDependencies).toHaveProperty('@graphql-codegen/typescript-stencil-apollo'); - // should not have other plugins - expect(Object.keys(pkg.devDependencies)).toHaveLength(4); + it('should use stencil related plugins when @stencil/core is found', async () => { + const fs = require('fs'); + fs.__setMockFiles(resolve(process.cwd(), 'package.json'), packageJson.withStencil); + // make sure we don't write stuff + const writeFileSpy = jest.spyOn(fs, 'writeFileSync').mockImplementation(); + // silent + jest.spyOn(console, 'log').mockImplementation(); + + useInputs({ + onTarget: [ENTER], // confirm stencil target + onSchema: [ENTER], // use default + onDocuments: [ENTER], + onPlugins: [ENTER], // use selected packages + onOutput: [ENTER], // use default output path + onIntrospection: ['n', ENTER], // no introspection, + onConfig: [ENTER], // use default config path + onScript: ['graphql', ENTER], // use custom npm script + }); + + await init(); + + expect(writeFileSpy).toHaveBeenCalledTimes(2); + + const pkg = JSON.parse(writeFileSpy.mock.calls[1][1] as string); + const config = load(writeFileSpy.mock.calls[0][1] as string) as Record; + + // should use default output path + expect(config.generates['src/generated/graphql.tsx']).toBeDefined(); + + const output: any = config.generates['src/generated/graphql.tsx']; + expect(output.plugins).toContainEqual('typescript'); + expect(output.plugins).toContainEqual('typescript-operations'); + expect(output.plugins).toContainEqual('typescript-stencil-apollo'); + expect(output.plugins).toHaveLength(3); + + // expected plugins + expect(pkg.devDependencies).toHaveProperty('@graphql-codegen/typescript'); + expect(pkg.devDependencies).toHaveProperty('@graphql-codegen/typescript-operations'); + expect(pkg.devDependencies).toHaveProperty('@graphql-codegen/typescript-stencil-apollo'); + // should not have other plugins + expect(Object.keys(pkg.devDependencies)).toHaveLength(4); + }); }); - it('should use typescript related plugins when typescript is found (node)', async () => { - const fs = require('fs'); - fs.__setMockFiles(resolve(process.cwd(), 'package.json'), packageJson.withTypescript); - // make sure we don't write stuff - const writeFileSpy = jest.spyOn(fs, 'writeFileSync').mockImplementation(); - // silent - jest.spyOn(console, 'log').mockImplementation(); - - useInputs({ - onTarget: [SELECT, ENTER], // confirm api target - onSchema: [ENTER], // use default - onPlugins: [ENTER], // use selected packages - onOutput: [ENTER], // use default output path - onIntrospection: ['n', ENTER], // no introspection, - onConfig: [ENTER], // use default config path - onScript: ['graphql', ENTER], // use custom npm script + describe('plugins suggestions non client-side setup', () => { + it('should use typescript related plugins when typescript is found (node)', async () => { + const fs = require('fs'); + fs.__setMockFiles(resolve(process.cwd(), 'package.json'), packageJson.withTypescript); + // make sure we don't write stuff + const writeFileSpy = jest.spyOn(fs, 'writeFileSync').mockImplementation(); + // silent + jest.spyOn(console, 'log').mockImplementation(); + + useInputs({ + onTarget: [SELECT, ENTER], // confirm api target + onSchema: [ENTER], // use default + onPlugins: [ENTER], // use selected packages + onOutput: [ENTER], // use default output path + onIntrospection: ['n', ENTER], // no introspection, + onConfig: [ENTER], // use default config path + onScript: ['graphql', ENTER], // use custom npm script + }); + + await init(); + + expect(writeFileSpy).toHaveBeenCalledTimes(2); + + const pkg = JSON.parse(writeFileSpy.mock.calls[1][1] as string); + const config = load(writeFileSpy.mock.calls[0][1] as string) as Record; + + // should use default output path + expect(config.generates['src/generated/graphql.ts']).toBeDefined(); + + const output: any = config.generates['src/generated/graphql.ts']; + expect(output.plugins).toContainEqual('typescript'); + expect(output.plugins).toContainEqual('typescript-resolvers'); + expect(output.plugins).toHaveLength(2); + + // expected plugins + expect(pkg.devDependencies).toHaveProperty('@graphql-codegen/typescript'); + expect(pkg.devDependencies).toHaveProperty('@graphql-codegen/typescript-resolvers'); + // should not have other plugins + expect(Object.keys(pkg.devDependencies)).toHaveLength(4); // 3 - because we have typescript package in devDeps }); - - await init(); - - expect(writeFileSpy).toHaveBeenCalledTimes(2); - - const pkg = JSON.parse(writeFileSpy.mock.calls[1][1] as string); - const config = load(writeFileSpy.mock.calls[0][1] as string) as Record; - - // should use default output path - expect(config.generates['src/generated/graphql.ts']).toBeDefined(); - - const output: any = config.generates['src/generated/graphql.ts']; - expect(output.plugins).toContainEqual('typescript'); - expect(output.plugins).toContainEqual('typescript-resolvers'); - expect(output.plugins).toHaveLength(2); - - // expected plugins - expect(pkg.devDependencies).toHaveProperty('@graphql-codegen/typescript'); - expect(pkg.devDependencies).toHaveProperty('@graphql-codegen/typescript-resolvers'); - // should not have other plugins - expect(Object.keys(pkg.devDependencies)).toHaveLength(4); // 3 - because we have typescript package in devDeps }); it('should have few default values', async () => { const fs = require('fs'); - fs.__setMockFiles(resolve(process.cwd(), 'package.json'), packageJson.withAngular); + fs.__setMockFiles(resolve(process.cwd(), 'package.json'), packageJson.withReact); // make sure we don't write stuff const writeFileSpy = jest.spyOn(fs, 'writeFileSync').mockImplementation(); const logSpy = jest.spyOn(console, 'log').mockImplementation(); const defaults = { - schema: 'http://localhost:4000', - documents: 'src/**/*.graphql', - output: 'src/generated/graphql.ts', - config: 'codegen.yml', + config: 'codegen.ts', }; useInputs({ onTarget: [ENTER], // confirm angular target onSchema: [ENTER], // use default onDocuments: [ENTER], - onPlugins: [ENTER], // use selected packages onOutput: [ENTER], // use default output path - onIntrospection: ['n', ENTER], // no introspection, + onIntrospection: [ENTER], // no introspection, onConfig: [ENTER], // use default config path onScript: ['graphql', ENTER], // use custom npm script }); @@ -289,21 +310,18 @@ describe('init', () => { await init(); const configFile = writeFileSpy.mock.calls[0][0] as string; - const config = load(writeFileSpy.mock.calls[0][1] as string) as Record; + const config = writeFileSpy.mock.calls[0][1] as string; const pkg = JSON.parse(writeFileSpy.mock.calls[1][1] as string); - expect(pkg.scripts.graphql).toEqual(`graphql-codegen --config ${defaults.config}`); + expect(pkg.scripts.graphql).toEqual(`graphql-codegen --config codegen.ts`); expect(configFile).toEqual(resolve(process.cwd(), defaults.config)); - expect(config.overwrite).toEqual(true); - expect(config.schema).toEqual(defaults.schema); - expect(config.documents).toEqual(defaults.documents); - expect(config.generates[defaults.output]).toBeDefined(); + expect(config).toMatchSnapshot(); expect(logSpy.mock.calls[2][0]).toContain(`Config file generated at ${bold(defaults.config)}`); }); it('should have few default values', async () => { const fs = require('fs'); - fs.__setMockFiles(resolve(process.cwd(), 'package.json'), packageJson.withAngular); + fs.__setMockFiles(resolve(process.cwd(), 'package.json'), packageJson.withReact); // make sure we don't write stuff const writeFileSpy = jest.spyOn(fs, 'writeFileSync').mockImplementation(); const logSpy = jest.spyOn(console, 'log').mockImplementation(); @@ -316,12 +334,11 @@ describe('init', () => { }; useInputs({ - onTarget: [ENTER], // confirm angular target + onTarget: [ENTER], // confirm target onSchema: [options.schema, ENTER], // use default onDocuments: [options.documents, ENTER], - onPlugins: [ENTER], // use selected packages onOutput: [options.output, ENTER], // use default output path - onIntrospection: ['n', ENTER], // no introspection, + onIntrospection: ['y', ENTER], // with introspection, onConfig: [options.config, ENTER], // use default config path onScript: [options.script, ENTER], // use custom npm script }); @@ -329,21 +346,18 @@ describe('init', () => { await init(); const configFile = writeFileSpy.mock.calls[0][0] as string; - const config = load(writeFileSpy.mock.calls[0][1] as string) as Record; + const config = writeFileSpy.mock.calls[0][1] as string; const pkg = JSON.parse(writeFileSpy.mock.calls[1][1] as string); expect(pkg.scripts[options.script]).toEqual(`graphql-codegen --config ${options.config}`); expect(configFile).toEqual(resolve(process.cwd(), options.config)); - expect(config.overwrite).toEqual(true); - expect(config.schema).toEqual(options.schema); - expect(config.documents).toEqual(options.documents); - expect(config.generates[options.output]).toBeDefined(); + expect(config).toMatchSnapshot(); expect(logSpy.mock.calls[2][0]).toContain(`Config file generated at ${bold(options.config)}`); }); it('custom setup', async () => { const fs = require('fs'); - fs.__setMockFiles(resolve(process.cwd(), 'package.json'), packageJson.withAngular); + fs.__setMockFiles(resolve(process.cwd(), 'package.json'), packageJson.withReact); // make sure we don't write stuff const writeFileSpy = jest.spyOn(fs, 'writeFileSync').mockImplementation(); const logSpy = jest.spyOn(console, 'log').mockImplementation(); @@ -351,10 +365,9 @@ describe('init', () => { const script = 'generate:types'; useInputs({ - onTarget: [ENTER], // confirm angular target + onTarget: [ENTER], // confirm target onSchema: [ENTER], // use default onDocuments: [documents, ENTER], - onPlugins: [ENTER], // use selected packages onOutput: [ENTER], // use default output path onIntrospection: ['y', ENTER], // no introspection, onConfig: [ENTER], // use default config path @@ -366,31 +379,15 @@ describe('init', () => { expect(writeFileSpy).toHaveBeenCalledTimes(2); const pkg = JSON.parse(writeFileSpy.mock.calls[1][1] as string); - const config = load(writeFileSpy.mock.calls[0][1] as string) as Record; + const config = writeFileSpy.mock.calls[0][1] as string; // config - // should overwrite - expect(config.overwrite).toEqual(true); - // should match default schema - expect(config.schema).toEqual('http://localhost:4000'); - // should match documents glob that we provided - expect(config.documents).toEqual(documents); - // should use default output path - expect(config.generates['src/generated/graphql.ts']).toBeDefined(); - // should include introspection - expect(config.generates['./graphql.schema.json']).toBeDefined(); - - const output: any = config.generates['src/generated/graphql.ts']; - expect(output.plugins).toContainEqual('typescript'); - expect(output.plugins).toContainEqual('typescript-operations'); - expect(output.plugins).toContainEqual('typescript-apollo-angular'); + expect(config).toMatchSnapshot(); // script name should match what we provided - expect(pkg.scripts[script]).toEqual('graphql-codegen --config codegen.yml'); + expect(pkg.scripts[script]).toEqual('graphql-codegen --config codegen.ts'); // expected plugins - expect(pkg.devDependencies).toHaveProperty('@graphql-codegen/typescript'); - expect(pkg.devDependencies).toHaveProperty('@graphql-codegen/typescript-operations'); - expect(pkg.devDependencies).toHaveProperty('@graphql-codegen/typescript-apollo-angular'); + expect(pkg.devDependencies).toHaveProperty('@graphql-codegen/introspection'); // should not have these plugins expect(pkg.devDependencies).not.toHaveProperty('@graphql-codegen/typescript-resolvers'); @@ -399,7 +396,7 @@ describe('init', () => { const doneMsg = logSpy.mock.calls[2][0]; expect(welcomeMsg).toContain(`Welcome to ${bold('GraphQL Code Generator')}`); - expect(doneMsg).toContain(`Config file generated at ${bold('codegen.yml')}`); + expect(doneMsg).toContain(`Config file generated at ${bold('codegen.ts')}`); expect(doneMsg).toContain(bold('$ npm install')); expect(doneMsg).toContain(bold(`$ npm run ${script}`)); }); @@ -424,11 +421,12 @@ describe('init', () => { [Tags.angular]: targets.includes(Tags.angular), [Tags.react]: targets.includes(Tags.react), [Tags.stencil]: targets.includes(Tags.stencil), - [Tags.browser]: targets.includes(Tags.browser), + [Tags.client]: targets.includes(Tags.client), [Tags.node]: targets.includes(Tags.node), [Tags.typescript]: targets.includes(Tags.typescript), [Tags.flow]: targets.includes(Tags.flow), [Tags.vue]: targets.includes(Tags.vue), + [Tags.graphqlRequest]: targets.includes(Tags.graphqlRequest), }) .filter(c => c.checked) .reduce((all, choice) => all.concat(choice.value), []); @@ -486,55 +484,22 @@ describe('init', () => { const { selected, available } = getPlugins([Tags.angular]); // available - expect(available).toHaveLength(7); - expect(available).toContainEqual('typescript'); - expect(available).toContainEqual('typescript-operations'); - expect(available).toContainEqual('typescript-apollo-angular'); - expect(available).toContainEqual('typescript-graphql-files-modules'); - expect(available).toContainEqual('typescript-document-nodes'); - expect(available).toContainEqual('fragment-matcher'); + expect(available).toHaveLength(2); + expect(available).toEqual(['fragment-matcher', 'typescript-apollo-angular']); // selected - expect(selected).toHaveLength(3); - expect(selected).toContainEqual('typescript'); - expect(selected).toContainEqual('typescript-operations'); - expect(selected).toContainEqual('typescript-apollo-angular'); + expect(selected).toHaveLength(1); + expect(selected).toEqual(['typescript-apollo-angular']); }); it('react', () => { const { selected, available } = getPlugins([Tags.react]); // available - expect(available).toHaveLength(9); - expect(available).toContainEqual('typescript'); - expect(available).toContainEqual('typescript-operations'); - expect(available).toContainEqual('typescript-react-apollo'); - expect(available).toContainEqual('typescript-graphql-files-modules'); - expect(available).toContainEqual('flow'); - expect(available).toContainEqual('flow-operations'); - expect(available).toContainEqual('fragment-matcher'); - // selected - expect(selected).toHaveLength(3); - expect(selected).toContainEqual('typescript'); - expect(selected).toContainEqual('typescript-operations'); - expect(selected).toContainEqual('typescript-react-apollo'); - }); - - it('react + typescript', () => { - const { selected, available } = getPlugins([Tags.react, Tags.typescript]); - - // available - expect(available).toHaveLength(7); - expect(available).toContainEqual('typescript'); - expect(available).toContainEqual('typescript-operations'); - expect(available).toContainEqual('typescript-react-apollo'); - expect(available).toContainEqual('typescript-graphql-files-modules'); - expect(available).toContainEqual('typescript-document-nodes'); + expect(available).toHaveLength(2); expect(available).toContainEqual('fragment-matcher'); + expect(available).toContainEqual('urql-introspection'); // selected - expect(selected).toHaveLength(3); - expect(selected).toContainEqual('typescript'); - expect(selected).toContainEqual('typescript-operations'); - expect(selected).toContainEqual('typescript-react-apollo'); + expect(selected).toHaveLength(0); }); it('react + flow', () => { @@ -568,22 +533,6 @@ describe('init', () => { expect(selected).toContainEqual('typescript-operations'); expect(selected).toContainEqual('typescript-stencil-apollo'); }); - - it('vanilla', () => { - const { selected, available } = getPlugins([Tags.browser]); - - // available - expect(available).toHaveLength(8); - expect(available).toContainEqual('typescript'); - expect(available).toContainEqual('typescript-operations'); - expect(available).toContainEqual('typescript-graphql-files-modules'); - expect(available).toContainEqual('typescript-document-nodes'); - expect(available).toContainEqual('flow'); - expect(available).toContainEqual('flow-operations'); - expect(available).toContainEqual('fragment-matcher'); - // selected - expect(selected).toHaveLength(0); - }); }); describe('plugins', () => { @@ -601,7 +550,7 @@ function useInputs(inputs: { onTarget: string[]; onSchema: string[]; onDocuments?: string[]; - onPlugins: string[]; + onPlugins?: string[]; onOutput: string[]; onIntrospection: string[]; onConfig: string[]; @@ -612,7 +561,7 @@ function useInputs(inputs: { inputs.onTarget, inputs.onSchema, inputs.onDocuments || [], - inputs.onPlugins, + inputs.onPlugins || [], inputs.onOutput, inputs.onIntrospection, inputs.onConfig, diff --git a/packages/plugins/typescript/gql-tag-operations/src/index.ts b/packages/plugins/typescript/gql-tag-operations/src/index.ts index 2e5a8021f80..243c2a378a5 100644 --- a/packages/plugins/typescript/gql-tag-operations/src/index.ts +++ b/packages/plugins/typescript/gql-tag-operations/src/index.ts @@ -25,30 +25,46 @@ export const plugin: PluginFunction<{ sourcesWithOperations: Array; useTypeImports?: boolean; augmentedModuleName?: string; + gqlTagName?: string; emitLegacyCommonJSImports?: boolean; -}> = (_, __, { sourcesWithOperations, useTypeImports, augmentedModuleName, emitLegacyCommonJSImports }, _info) => { - if (!sourcesWithOperations) { - return ''; - } - +}> = ( + _, + __, + { sourcesWithOperations, useTypeImports, augmentedModuleName, gqlTagName = 'gql', emitLegacyCommonJSImports }, + _info +) => { if (augmentedModuleName == null) { - return [ - `import * as graphql from './graphql${emitLegacyCommonJSImports ? '' : '.js'}';\n`, + const code = [ + `import * as types from './graphql${emitLegacyCommonJSImports ? '' : '.js'}';\n`, `${ useTypeImports ? 'import type' : 'import' } { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';\n`, `\n`, - ...getDocumentRegistryChunk(sourcesWithOperations), - `\n`, - ...getGqlOverloadChunk(sourcesWithOperations, 'lookup', emitLegacyCommonJSImports), - `\n`, - `export function gql(source: string): unknown;\n`, - `export function gql(source: string) {\n`, - ` return (documents as any)[source] ?? {};\n`, - `}\n`, - `\n`, - ...documentTypePartial, - ].join(``); + ]; + + if (sourcesWithOperations.length > 0) { + code.push( + [ + ...getDocumentRegistryChunk(sourcesWithOperations), + `\n`, + ...getGqlOverloadChunk(sourcesWithOperations, gqlTagName, 'lookup', emitLegacyCommonJSImports), + ].join('') + ); + } + + code.push( + [ + `\n`, + `export function ${gqlTagName}(source: string): unknown;\n`, + `export function ${gqlTagName}(source: string) {\n`, + ` return (documents as any)[source] ?? {};\n`, + `}\n`, + `\n`, + ...documentTypePartial, + ].join('') + ); + + return code.join(''); } return [ @@ -56,8 +72,10 @@ export const plugin: PluginFunction<{ `declare module "${augmentedModuleName}" {`, [ `\n`, - ...getGqlOverloadChunk(sourcesWithOperations, 'augmented', emitLegacyCommonJSImports), - `export function gql(source: string): unknown;\n`, + ...(sourcesWithOperations.length > 0 + ? getGqlOverloadChunk(sourcesWithOperations, gqlTagName, 'augmented', emitLegacyCommonJSImports) + : []), + `export function ${gqlTagName}(source: string): unknown;\n`, `\n`, ...documentTypePartial, ] @@ -75,7 +93,7 @@ function getDocumentRegistryChunk(sourcesWithOperations: Array, + gqlTagName: string, mode: Mode, emitLegacyCommonJSImports?: boolean ) { @@ -102,7 +121,7 @@ function getGqlOverloadChunk( : emitLegacyCommonJSImports ? `typeof import('./graphql').${operations[0].initialName}` : `typeof import('./graphql.js').${operations[0].initialName}`; - lines.add(`export function gql(source: ${JSON.stringify(originalString)}): ${returnType};\n`); + lines.add(`export function ${gqlTagName}(source: ${JSON.stringify(originalString)}): ${returnType};\n`); } return lines; diff --git a/packages/presets/client/jest.config.js b/packages/presets/client/jest.config.js new file mode 100644 index 00000000000..7191c6796d0 --- /dev/null +++ b/packages/presets/client/jest.config.js @@ -0,0 +1 @@ +module.exports = require('../../../jest.project')({ dirname: __dirname }); diff --git a/packages/presets/client/package.json b/packages/presets/client/package.json new file mode 100644 index 00000000000..9de64ba3820 --- /dev/null +++ b/packages/presets/client/package.json @@ -0,0 +1,64 @@ +{ + "name": "@graphql-codegen/client-preset", + "version": "1.0.0", + "description": "GraphQL Code Generator preset for client.", + "repository": { + "type": "git", + "url": "https://github.com/dotansimha/graphql-code-generator.git", + "directory": "packages/presets/client" + }, + "license": "MIT", + "scripts": { + "lint": "eslint **/*.ts", + "test": "jest --no-watchman --config ../../../jest.config.js" + }, + "devDependencies": { + "@types/babel__helper-plugin-utils": "7.10.0", + "@types/babel__template": "7.4.1" + }, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5", + "@babel/template": "^7.15.4", + "@graphql-codegen/add": "^3.2.1", + "@graphql-codegen/typed-document-node": "^2.3.3", + "@graphql-codegen/typescript": "^2.7.3", + "@graphql-codegen/typescript-operations": "^2.5.3", + "@graphql-codegen/gql-tag-operations": "^1.4.1", + "@graphql-codegen/plugin-helpers": "^2.6.2", + "@graphql-codegen/visitor-plugin-common": "^2.12.1", + "@graphql-typed-document-node/core": "3.1.1", + "@graphql-tools/utils": "^8.8.0", + "tslib": "~2.4.0" + }, + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" + }, + "main": "dist/cjs/index.js", + "module": "dist/esm/index.js", + "exports": { + ".": { + "require": { + "types": "./dist/typings/index.d.cts", + "default": "./dist/cjs/index.js" + }, + "import": { + "types": "./dist/typings/index.d.ts", + "default": "./dist/esm/index.js" + }, + "default": { + "types": "./dist/typings/index.d.ts", + "default": "./dist/esm/index.js" + } + }, + "./package.json": "./package.json" + }, + "typings": "dist/typings/index.d.ts", + "typescript": { + "definition": "dist/typings/index.d.ts" + }, + "publishConfig": { + "directory": "dist", + "access": "public" + }, + "type": "module" +} diff --git a/packages/presets/client/src/babel.ts b/packages/presets/client/src/babel.ts new file mode 100644 index 00000000000..bca7f7ef0b2 --- /dev/null +++ b/packages/presets/client/src/babel.ts @@ -0,0 +1,87 @@ +import type { PluginObj, PluginPass } from '@babel/core'; +import { declare } from '@babel/helper-plugin-utils'; +import template from '@babel/template'; +import type { NodePath } from '@babel/traverse'; +import type { Program } from '@babel/types'; +import { ClientSideBaseVisitor } from '@graphql-codegen/visitor-plugin-common'; +import { buildSchema, parse } from 'graphql'; +import * as path from 'path'; + +const noopSchema = buildSchema(`type Query { _: Int }`); + +export default declare((api, opts): PluginObj => { + const visitor = new ClientSideBaseVisitor(noopSchema, [], {}, {}); + + const artifactDirectory = opts['artifactDirectory'] ?? ''; + const gqlTagName = opts['gqlTagName'] || 'gql'; + + let program: NodePath; + return { + name: 'client-preset', + visitor: { + Program(path) { + program = path; + }, + CallExpression(path, state) { + if (path.node.callee.type !== 'Identifier' || path.node.callee.name !== gqlTagName) { + return; + } + const [argument] = path.node.arguments; + if (argument == null) { + return; + } + if (argument.type !== 'TemplateLiteral') { + return; + } + + const [content] = argument.quasis; + const ast = parse(content.value.raw); + + const [firstDefinition] = ast.definitions; + + if (firstDefinition.kind !== 'FragmentDefinition' && firstDefinition.kind !== 'OperationDefinition') { + return; + } + + if (firstDefinition.name == null) { + return; + } + + const operationOrFragmentName = + firstDefinition.kind === 'OperationDefinition' + ? visitor.getOperationVariableName(firstDefinition) + : visitor.getFragmentVariableName(firstDefinition); + + const importPath = getRelativeImportPath(state, artifactDirectory); + + const importDeclaration = template(` + import { %%importName%% } from %%importPath%% + `); + program.unshiftContainer( + 'body', + importDeclaration({ + importName: api.types.identifier(operationOrFragmentName), + importPath: api.types.stringLiteral(importPath), + }) + ); + path.replaceWith(api.types.identifier(operationOrFragmentName)); + }, + }, + }; +}); + +function getRelativeImportPath(state: PluginPass, artifactDirectory: string, fileToRequire = 'graphql'): string { + if (state.file == null) { + throw new Error('Babel state is missing expected file name'); + } + + const { filename } = state.file.opts; + + const relative = path.relative(path.dirname(filename), path.resolve(artifactDirectory)); + + const relativeReference = relative.length === 0 || !relative.startsWith('.') ? './' : ''; + + const platformSpecificPath = relativeReference + path.join(relative, fileToRequire); + // ensure windows paths are written as unix paths + return platformSpecificPath.split(path.sep).join(path.posix.sep); +} diff --git a/packages/presets/client/src/fragment-masking-plugin.ts b/packages/presets/client/src/fragment-masking-plugin.ts new file mode 100644 index 00000000000..59be8117e61 --- /dev/null +++ b/packages/presets/client/src/fragment-masking-plugin.ts @@ -0,0 +1,88 @@ +import type { PluginFunction } from '@graphql-codegen/plugin-helpers'; + +const fragmentTypeHelper = ` +export type FragmentType> = TDocumentType extends DocumentNode< + infer TType, + any +> + ? TType extends { ' $fragmentName': infer TKey } + ? TKey extends string + ? { ' $fragmentRefs': { [key in TKey]: TType } } + : never + : never + : never;`; + +const defaultUnmaskFunctionName = 'useFragment'; + +const modifyType = (rawType: string, opts: { nullable: boolean; list: 'with-list' | 'only-list' | false }) => + `${ + opts.list === 'only-list' + ? `ReadonlyArray<${rawType}>` + : opts.list === 'with-list' + ? `${rawType} | ReadonlyArray<${rawType}>` + : rawType + }${opts.nullable ? ' | null | undefined' : ''}`; + +const createUnmaskFunctionTypeDefinition = ( + unmaskFunctionName = defaultUnmaskFunctionName, + opts: { nullable: boolean; list: 'with-list' | 'only-list' | false } +) => `export function ${unmaskFunctionName}( + _documentNode: DocumentNode, + fragmentType: ${modifyType('FragmentType>', opts)} +): ${modifyType('TType', opts)}`; + +const createUnmaskFunctionTypeDefinitions = (unmaskFunctionName = defaultUnmaskFunctionName) => [ + `// return non-nullable if \`fragmentType\` is non-nullable\n${createUnmaskFunctionTypeDefinition( + unmaskFunctionName, + { nullable: false, list: false } + )}`, + `// return nullable if \`fragmentType\` is nullable\n${createUnmaskFunctionTypeDefinition(unmaskFunctionName, { + nullable: true, + list: false, + })}`, + `// return array of non-nullable if \`fragmentType\` is array of non-nullable\n${createUnmaskFunctionTypeDefinition( + unmaskFunctionName, + { nullable: false, list: 'only-list' } + )}`, + `// return array of nullable if \`fragmentType\` is array of nullable\n${createUnmaskFunctionTypeDefinition( + unmaskFunctionName, + { nullable: true, list: 'only-list' } + )}`, +]; + +const createUnmaskFunction = (unmaskFunctionName = defaultUnmaskFunctionName) => ` +${createUnmaskFunctionTypeDefinitions(unmaskFunctionName).join(';\n')} +${createUnmaskFunctionTypeDefinition(unmaskFunctionName, { nullable: true, list: 'with-list' })} { + return fragmentType as any +} +`; + +/** + * Plugin for generating fragment masking helper functions. + */ +export const plugin: PluginFunction<{ + useTypeImports?: boolean; + augmentedModuleName?: string; + unmaskFunctionName?: string; +}> = (_, __, { useTypeImports, augmentedModuleName, unmaskFunctionName }, _info) => { + const documentNodeImport = `${ + useTypeImports ? 'import type' : 'import' + } { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';\n`; + + if (augmentedModuleName == null) { + return [documentNodeImport, `\n`, fragmentTypeHelper, `\n`, createUnmaskFunction(unmaskFunctionName)].join(``); + } + + return [ + documentNodeImport, + `declare module "${augmentedModuleName}" {`, + [ + ...fragmentTypeHelper.split(`\n`), + `\n`, + ...createUnmaskFunctionTypeDefinitions(unmaskFunctionName).join('\n').split('\n'), + ] + .map(line => (line === `\n` || line === '' ? line : ` ${line}`)) + .join(`\n`), + `}`, + ].join(`\n`); +}; diff --git a/packages/presets/client/src/index.ts b/packages/presets/client/src/index.ts new file mode 100644 index 00000000000..93de6ba954b --- /dev/null +++ b/packages/presets/client/src/index.ts @@ -0,0 +1,200 @@ +import * as addPlugin from '@graphql-codegen/add'; +import type { Types } from '@graphql-codegen/plugin-helpers'; +import * as typedDocumentNodePlugin from '@graphql-codegen/typed-document-node'; +import * as typescriptOperationPlugin from '@graphql-codegen/typescript-operations'; +import * as typescriptPlugin from '@graphql-codegen/typescript'; + +import * as gqlTagPlugin from '@graphql-codegen/gql-tag-operations'; +import { processSources } from './process-sources.js'; +import { ClientSideBaseVisitor } from '@graphql-codegen/visitor-plugin-common'; +import babelOptimizerPlugin from './babel.js'; +import * as fragmentMaskingPlugin from './fragment-masking-plugin.js'; + +export type FragmentMaskingConfig = { + /** @description Name of the function that should be used for unmasking a masked fragment property. + * @default `'useFragment'` + */ + unmaskFunctionName?: string; +}; + +export type ClientPresetConfig = { + /** + * @description Fragment masking hides data from components and only allows accessing the data by using a unmasking function. + * @exampleMarkdown + * ```tsx + * const config = { + * schema: 'https://swapi-graphql.netlify.app/.netlify/functions/index', + * documents: ['src/**\/*.tsx', '!src\/gql/**\/*'], + * generates: { + * './src/gql/': { + * preset: 'front-end', + * presetConfig: { + * fragmentMasking: false, + * } + * }, + * }, + * }; + * export default config; + * ``` + */ + fragmentMasking?: FragmentMaskingConfig | boolean; + /** + * @description Specify the name of the "graphql tag" function to use + * @default "graphql" + * + * E.g. `graphql` or `gql`. + * + * @exampleMarkdown + * ```tsx + * const config = { + * schema: 'https://swapi-graphql.netlify.app/.netlify/functions/index', + * documents: ['src/**\/*.tsx', '!src\/gql/**\/*'], + * generates: { + * './src/gql/': { + * preset: 'front-end', + * presetConfig: { + * gqlTagName: 'gql', + * } + * }, + * }, + * }; + * export default config; + * ``` + */ + gqlTagName?: string; +}; + +export const preset: Types.OutputPreset = { + prepareDocuments: (outputFilePath, outputSpecificDocuments) => [...outputSpecificDocuments, `!${outputFilePath}`], + buildGeneratesSection: options => { + const reexports: Array = []; + + const visitor = new ClientSideBaseVisitor(options.schemaAst!, [], options.config, options.config); + let fragmentMaskingConfig: FragmentMaskingConfig | null = null; + + if (typeof options?.presetConfig?.fragmentMasking === 'object') { + fragmentMaskingConfig = options.presetConfig.fragmentMasking; + } else if (options?.presetConfig?.fragmentMasking !== false) { + // `true` by default + fragmentMaskingConfig = {}; + } + + const isMaskingFragments = fragmentMaskingConfig != null; + + const sourcesWithOperations = processSources(options.documents, node => { + if (node.kind === 'FragmentDefinition') { + return visitor.getFragmentVariableName(node); + } + return visitor.getOperationVariableName(node); + }); + const sources = sourcesWithOperations.map(({ source }) => source); + + const pluginMap = { + ...options.pluginMap, + [`add`]: addPlugin, + [`typescript`]: typescriptPlugin, + [`typescript-operations`]: typescriptOperationPlugin, + [`typed-document-node`]: typedDocumentNodePlugin, + [`gen-dts`]: gqlTagPlugin, + }; + + const plugins: Array = [ + { [`add`]: { content: `/* eslint-disable */` } }, + { [`typescript`]: {} }, + { [`typescript-operations`]: {} }, + { [`typed-document-node`]: {} }, + ...options.plugins, + ]; + + const genDtsPlugins: Array = [ + { [`add`]: { content: `/* eslint-disable */` } }, + { [`gen-dts`]: { sourcesWithOperations } }, + ]; + + const gqlArtifactFileExtension = '.ts'; + reexports.push('gql'); + + const config = { + ...options.config, + inlineFragmentTypes: isMaskingFragments ? 'mask' : options.config['inlineFragmentTypes'], + }; + + let fragmentMaskingFileGenerateConfig: Types.GenerateOptions | null = null; + + if (isMaskingFragments === true) { + const fragmentMaskingArtifactFileExtension = '.ts'; + + reexports.push('fragment-masking'); + + fragmentMaskingFileGenerateConfig = { + filename: `${options.baseOutputDir}/fragment-masking${fragmentMaskingArtifactFileExtension}`, + pluginMap: { + [`fragment-masking`]: fragmentMaskingPlugin, + }, + plugins: [ + { + [`fragment-masking`]: {}, + }, + ], + schema: options.schema, + config: { + useTypeImports: options.config.useTypeImports, + unmaskFunctionName: fragmentMaskingConfig.unmaskFunctionName, + }, + documents: [], + }; + } + + let indexFileGenerateConfig: Types.GenerateOptions | null = null; + + const reexportsExtension = options.config.emitLegacyCommonJSImports ? '' : '.js'; + + if (reexports.length) { + indexFileGenerateConfig = { + filename: `${options.baseOutputDir}/index.ts`, + pluginMap: { + [`add`]: addPlugin, + }, + plugins: [ + { + [`add`]: { + content: reexports.map(moduleName => `export * from "./${moduleName}${reexportsExtension}"`).join('\n'), + }, + }, + ], + schema: options.schema, + config: {}, + documents: [], + }; + } + + return [ + { + filename: `${options.baseOutputDir}/graphql.ts`, + plugins, + pluginMap, + schema: options.schema, + config: { + inlineFragmentTypes: isMaskingFragments ? 'mask' : options.config['inlineFragmentTypes'], + useTypeImports: options.config.useTypeImports, + }, + documents: sources, + }, + { + filename: `${options.baseOutputDir}/gql${gqlArtifactFileExtension}`, + plugins: genDtsPlugins, + pluginMap, + schema: options.schema, + config: { + ...config, + gqlTagName: options.presetConfig.gqlTagName || 'graphql', + }, + documents: sources, + }, + ...(fragmentMaskingFileGenerateConfig ? [fragmentMaskingFileGenerateConfig] : []), + ...(indexFileGenerateConfig ? [indexFileGenerateConfig] : []), + ]; + }, +}; + +export { babelOptimizerPlugin }; diff --git a/packages/presets/client/src/process-sources.ts b/packages/presets/client/src/process-sources.ts new file mode 100644 index 00000000000..2b5337b6dd9 --- /dev/null +++ b/packages/presets/client/src/process-sources.ts @@ -0,0 +1,86 @@ +import { Source } from '@graphql-tools/utils'; +import { SourceWithOperations, OperationOrFragment } from '@graphql-codegen/gql-tag-operations'; +import { FragmentDefinitionNode, OperationDefinitionNode } from 'graphql'; + +export type BuildNameFunction = (type: OperationDefinitionNode | FragmentDefinitionNode) => string; + +export function processSources(sources: Array, buildName: BuildNameFunction) { + const sourcesWithOperations: Array = []; + + for (const originalSource of sources) { + const source = fixLinebreaks(originalSource); + const { document } = source; + const operations: Array = []; + + for (const definition of document?.definitions ?? []) { + if (definition?.kind !== `OperationDefinition` && definition?.kind !== 'FragmentDefinition') continue; + + if (definition.name?.kind !== `Name`) continue; + + operations.push({ + initialName: buildName(definition), + definition, + }); + } + + if (operations.length === 0) continue; + + sourcesWithOperations.push({ + source, + operations, + }); + } + + return sourcesWithOperations; +} + +/** + * https://github.com/dotansimha/graphql-code-generator/issues/7362 + * + * Source file is read by @graphql/tools using fs.promises.readFile, + * which means that the linebreaks are read as-is and the result will be different + * depending on the OS: it will contain LF (\n) on Linux/MacOS and CRLF (\r\n) on Windows. + * + * In most scenarios that would be OK. However, front-end preset is using the resulting string + * as a TypeScript type. Which means that the string will be compared against a template literal, + * for example: + * + *

+ * `
+ * query a {
+ *    a
+ *  }
+ * ` === '\n query a {\n    a\n  }\n '
+ * 
+ * + * According to clause 12.8.6.2 of ECMAScript Language Specification + * (https://tc39.es/ecma262/#sec-static-semantics-trv), + * when comparing strings, JavaScript doesn't care which linebreaks does the source file contain, + * any linebreak (CR, LF or CRLF) is LF from JavaScript standpoint + * (otherwise the result of the above comparison would be OS-dependent, which doesn't make sense). + * + * Therefore gql-tag-operation would break on Windows as it would generate + * + * '\r\n query a {\r\n a\r\n }\r\n ' + * + * which is NOT equal to + * + *

+ * `
+ * query a {
+ *    a
+ *  }
+ * `
+ * 
+ * + * Therefore we need to replace \r\n with \n in the string. + * + * @param source + */ +function fixLinebreaks(source: Source) { + const fixedSource = { ...source }; + + fixedSource.rawSDL = source.rawSDL.replace(/\r\n/g, '\n'); + + return fixedSource; +} diff --git a/packages/presets/client/tests/babel.spec.ts b/packages/presets/client/tests/babel.spec.ts new file mode 100644 index 00000000000..03ec78b02d4 --- /dev/null +++ b/packages/presets/client/tests/babel.spec.ts @@ -0,0 +1,50 @@ +import { transformFileSync } from '@babel/core'; +import * as path from 'path'; +import babelPlugin from '../src/babel.js'; + +describe('client-preset > babelPlugin', () => { + test('can imports files in the same directory', () => { + const result = transformFileSync(path.join(__dirname, 'fixtures/simple-uppercase-operation-name.ts'), { + plugins: [[babelPlugin, { artifactDirectory: path.join(__dirname, 'fixtures') }]], + babelrc: false, + configFile: false, + }).code; + expect(result).toMatchInlineSnapshot(` + "import { CFragmentDoc } from "./graphql"; + import { BDocument } from "./graphql"; + import { ADocument } from "./graphql"; + + /* eslint-disable @typescript-eslint/ban-ts-comment */ + //@ts-ignore + import gql from 'gql-tag'; //@ts-ignore + + const A = ADocument; //@ts-ignore + + const B = BDocument; //@ts-ignore + + const C = CFragmentDoc;" + `); + }); + test('can import files in another directory', () => { + const result = transformFileSync(path.join(__dirname, 'fixtures/simple-uppercase-operation-name.ts'), { + plugins: [[babelPlugin, { artifactDirectory: __dirname }]], + babelrc: false, + configFile: false, + }).code; + expect(result).toMatchInlineSnapshot(` + "import { CFragmentDoc } from "../graphql"; + import { BDocument } from "../graphql"; + import { ADocument } from "../graphql"; + + /* eslint-disable @typescript-eslint/ban-ts-comment */ + //@ts-ignore + import gql from 'gql-tag'; //@ts-ignore + + const A = ADocument; //@ts-ignore + + const B = BDocument; //@ts-ignore + + const C = CFragmentDoc;" + `); + }); +}); diff --git a/packages/presets/client/tests/client-preset.spec.ts b/packages/presets/client/tests/client-preset.spec.ts new file mode 100644 index 00000000000..decdc2fc620 --- /dev/null +++ b/packages/presets/client/tests/client-preset.spec.ts @@ -0,0 +1,713 @@ +import { executeCodegen } from '@graphql-codegen/cli'; +import { mergeOutputs } from '@graphql-codegen/plugin-helpers'; +import '@graphql-codegen/testing'; +import { validateTs } from '@graphql-codegen/testing'; +import { readFileSync } from 'fs'; +import path from 'path'; +import { preset } from '../src/index.js'; + +describe('client-preset', () => { + it('can generate simple examples uppercase names', async () => { + const result = await executeCodegen({ + schema: [ + /* GraphQL */ ` + type Query { + a: String + b: String + c: String + } + `, + ], + documents: path.join(__dirname, 'fixtures/simple-uppercase-operation-name.ts'), + generates: { + out1: { + preset, + plugins: [], + }, + }, + }); + + expect(result).toHaveLength(4); + // index.ts (re-exports) + const indexFile = result.find(file => file.filename === 'out1/index.ts'); + expect(indexFile.content).toEqual(`export * from "./gql" +export * from "./fragment-masking"`); + + // gql.ts + const gqlFile = result.find(file => file.filename === 'out1/gql.ts'); + expect(gqlFile.content).toMatchInlineSnapshot(` + "/* eslint-disable */ + import * as types from './graphql'; + import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; + + const documents = { + "\\n query A {\\n a\\n }\\n": types.ADocument, + "\\n query B {\\n b\\n }\\n": types.BDocument, + "\\n fragment C on Query {\\n c\\n }\\n": types.CFragmentDoc, + }; + + export function graphql(source: "\\n query A {\\n a\\n }\\n"): (typeof documents)["\\n query A {\\n a\\n }\\n"]; + export function graphql(source: "\\n query B {\\n b\\n }\\n"): (typeof documents)["\\n query B {\\n b\\n }\\n"]; + export function graphql(source: "\\n fragment C on Query {\\n c\\n }\\n"): (typeof documents)["\\n fragment C on Query {\\n c\\n }\\n"]; + + export function graphql(source: string): unknown; + export function graphql(source: string) { + return (documents as any)[source] ?? {}; + } + + export type DocumentType> = TDocumentNode extends DocumentNode< infer TType, any> ? TType : never;" + `); + + // graphql.ts + const graphqlFile = result.find(file => file.filename === 'out1/gql.ts'); + expect(graphqlFile).toBeDefined(); + }); + + it('can generate simple examples lowercase names', async () => { + const result = await executeCodegen({ + schema: [ + /* GraphQL */ ` + type Query { + a: String + b: String + c: String + } + `, + ], + documents: path.join(__dirname, 'fixtures/simple-lowercase-operation-name.ts'), + generates: { + out1: { + preset, + plugins: [], + }, + }, + }); + + expect(result).toHaveLength(4); + // index.ts (re-exports) + const indexFile = result.find(file => file.filename === 'out1/index.ts'); + expect(indexFile.content).toEqual(`export * from "./gql" +export * from "./fragment-masking"`); + + // gql.ts + const gqlFile = result.find(file => file.filename === 'out1/gql.ts'); + expect(gqlFile.content).toMatchInlineSnapshot(` + "/* eslint-disable */ + import * as types from './graphql'; + import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; + + const documents = { + "\\n query a {\\n a\\n }\\n": types.ADocument, + "\\n query b {\\n b\\n }\\n": types.BDocument, + "\\n fragment C on Query {\\n c\\n }\\n": types.CFragmentDoc, + }; + + export function graphql(source: "\\n query a {\\n a\\n }\\n"): (typeof documents)["\\n query a {\\n a\\n }\\n"]; + export function graphql(source: "\\n query b {\\n b\\n }\\n"): (typeof documents)["\\n query b {\\n b\\n }\\n"]; + export function graphql(source: "\\n fragment C on Query {\\n c\\n }\\n"): (typeof documents)["\\n fragment C on Query {\\n c\\n }\\n"]; + + export function graphql(source: string): unknown; + export function graphql(source: string) { + return (documents as any)[source] ?? {}; + } + + export type DocumentType> = TDocumentNode extends DocumentNode< infer TType, any> ? TType : never;" + `); + + // graphql.ts + const graphqlFile = result.find(file => file.filename === 'out1/gql.ts'); + expect(graphqlFile).toBeDefined(); + }); + + it('generates \\n regardless of whether the source contains LF or CRLF', async () => { + const result = await executeCodegen({ + schema: [ + /* GraphQL */ ` + type Query { + a: String + b: String + c: String + } + `, + ], + documents: path.join(__dirname, 'fixtures/crlf-operation.ts'), + generates: { + out1: { + preset, + plugins: [], + }, + }, + }); + const gqlFile = result.find(file => file.filename === 'out1/gql.ts'); + expect(gqlFile.content).toMatchInlineSnapshot(` + "/* eslint-disable */ + import * as types from './graphql'; + import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; + + const documents = { + "\\n query a {\\n a\\n }\\n": types.ADocument, + "\\n query b {\\n b\\n }\\n": types.BDocument, + "\\n fragment C on Query {\\n c\\n }\\n": types.CFragmentDoc, + }; + + export function graphql(source: "\\n query a {\\n a\\n }\\n"): (typeof documents)["\\n query a {\\n a\\n }\\n"]; + export function graphql(source: "\\n query b {\\n b\\n }\\n"): (typeof documents)["\\n query b {\\n b\\n }\\n"]; + export function graphql(source: "\\n fragment C on Query {\\n c\\n }\\n"): (typeof documents)["\\n fragment C on Query {\\n c\\n }\\n"]; + + export function graphql(source: string): unknown; + export function graphql(source: string) { + return (documents as any)[source] ?? {}; + } + + export type DocumentType> = TDocumentNode extends DocumentNode< infer TType, any> ? TType : never;" + `); + }); + + it("follows 'useTypeImports': true", async () => { + const result = await executeCodegen({ + schema: [ + /* GraphQL */ ` + type Query { + a: String + b: String + c: String + } + `, + ], + documents: path.join(__dirname, 'fixtures/simple-uppercase-operation-name.ts'), + generates: { + out1: { + preset, + plugins: [], + }, + }, + config: { + useTypeImports: true, + }, + }); + + expect(result.length).toBe(4); + const gqlFile = result.find(file => file.filename === 'out1/gql.ts'); + expect(gqlFile.content).toMatchInlineSnapshot(` + "/* eslint-disable */ + import * as types from './graphql'; + import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; + + const documents = { + "\\n query A {\\n a\\n }\\n": types.ADocument, + "\\n query B {\\n b\\n }\\n": types.BDocument, + "\\n fragment C on Query {\\n c\\n }\\n": types.CFragmentDoc, + }; + + export function graphql(source: "\\n query A {\\n a\\n }\\n"): (typeof documents)["\\n query A {\\n a\\n }\\n"]; + export function graphql(source: "\\n query B {\\n b\\n }\\n"): (typeof documents)["\\n query B {\\n b\\n }\\n"]; + export function graphql(source: "\\n fragment C on Query {\\n c\\n }\\n"): (typeof documents)["\\n fragment C on Query {\\n c\\n }\\n"]; + + export function graphql(source: string): unknown; + export function graphql(source: string) { + return (documents as any)[source] ?? {}; + } + + export type DocumentType> = TDocumentNode extends DocumentNode< infer TType, any> ? TType : never;" + `); + const graphqlFile = result.find(file => file.filename === 'out1/graphql.ts'); + expect(graphqlFile.content).toMatchInlineSnapshot(` + "/* eslint-disable */ + import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; + export type Maybe = T | null; + export type InputMaybe = Maybe; + export type Exact = { [K in keyof T]: T[K] }; + export type MakeOptional = Omit & { [SubKey in K]?: Maybe }; + export type MakeMaybe = Omit & { [SubKey in K]: Maybe }; + /** All built-in and custom scalars, mapped to their actual values */ + export type Scalars = { + ID: string; + String: string; + Boolean: boolean; + Int: number; + Float: number; + }; + + export type Query = { + __typename?: 'Query'; + a?: Maybe; + b?: Maybe; + c?: Maybe; + }; + + export type AQueryVariables = Exact<{ [key: string]: never; }>; + + + export type AQuery = { __typename?: 'Query', a?: string | null }; + + export type BQueryVariables = Exact<{ [key: string]: never; }>; + + + export type BQuery = { __typename?: 'Query', b?: string | null }; + + export type CFragment = { __typename?: 'Query', c?: string | null } & { ' $fragmentName': 'CFragment' }; + + export const CFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"C"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Query"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"c"}}]}}]} as unknown as DocumentNode; + export const ADocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"A"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"a"}}]}}]} as unknown as DocumentNode; + export const BDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"B"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"b"}}]}}]} as unknown as DocumentNode;" + `); + + expect(graphqlFile.content).toContain( + "import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'" + ); + expect(gqlFile.content).toContain( + "import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'" + ); + }); + + it('prevent duplicate operations', async () => { + const result = await executeCodegen({ + schema: [ + /* GraphQL */ ` + type Query { + a: String + } + `, + ], + documents: path.join(__dirname, 'fixtures/duplicate-operation.ts'), + generates: { + out1: { + preset, + plugins: [], + }, + }, + config: { + useTypeImports: true, + }, + }); + + expect(result.length).toBe(4); + const gqlFile = result.find(file => file.filename === 'out1/gql.ts'); + expect(gqlFile.content).toMatchInlineSnapshot(` + "/* eslint-disable */ + import * as types from './graphql'; + import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; + + const documents = { + "\\n query a {\\n a\\n }\\n": types.ADocument, + }; + + export function graphql(source: "\\n query a {\\n a\\n }\\n"): (typeof documents)["\\n query a {\\n a\\n }\\n"]; + + export function graphql(source: string): unknown; + export function graphql(source: string) { + return (documents as any)[source] ?? {}; + } + + export type DocumentType> = TDocumentNode extends DocumentNode< infer TType, any> ? TType : never;" + `); + const graphqlFile = result.find(file => file.filename === 'out1/graphql.ts'); + expect(graphqlFile.content).toMatchInlineSnapshot(` + "/* eslint-disable */ + import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; + export type Maybe = T | null; + export type InputMaybe = Maybe; + export type Exact = { [K in keyof T]: T[K] }; + export type MakeOptional = Omit & { [SubKey in K]?: Maybe }; + export type MakeMaybe = Omit & { [SubKey in K]: Maybe }; + /** All built-in and custom scalars, mapped to their actual values */ + export type Scalars = { + ID: string; + String: string; + Boolean: boolean; + Int: number; + Float: number; + }; + + export type Query = { + __typename?: 'Query'; + a?: Maybe; + }; + + export type AQueryVariables = Exact<{ [key: string]: never; }>; + + + export type AQuery = { __typename?: 'Query', a?: string | null }; + + + export const ADocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"a"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"a"}}]}}]} as unknown as DocumentNode;" + `); + + expect(gqlFile.content.match(/query a {/g).length).toBe(3); + }); + + describe('fragment masking', () => { + it('fragmentMasking: false', async () => { + const result = await executeCodegen({ + schema: [ + /* GraphQL */ ` + type Query { + a: String + b: String + c: String + } + `, + ], + documents: path.join(__dirname, 'fixtures/simple-uppercase-operation-name.ts'), + generates: { + out1: { + preset, + plugins: [], + presetConfig: { + fragmentMasking: false, + }, + }, + }, + }); + + expect(result).toHaveLength(3); + const fileNames = result.map(res => res.filename); + expect(fileNames).toContain('out1/index.ts'); + expect(fileNames).toContain('out1/gql.ts'); + expect(fileNames).toContain('out1/graphql.ts'); + + const indexFile = result.find(file => file.filename === 'out1/index.ts'); + expect(indexFile.content).toMatchInlineSnapshot(`"export * from "./gql""`); + const gqlFile = result.find(file => file.filename === 'out1/gql.ts'); + expect(gqlFile.content).toMatchInlineSnapshot(` + "/* eslint-disable */ + import * as types from './graphql'; + import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; + + const documents = { + "\\n query A {\\n a\\n }\\n": types.ADocument, + "\\n query B {\\n b\\n }\\n": types.BDocument, + "\\n fragment C on Query {\\n c\\n }\\n": types.CFragmentDoc, + }; + + export function graphql(source: "\\n query A {\\n a\\n }\\n"): (typeof documents)["\\n query A {\\n a\\n }\\n"]; + export function graphql(source: "\\n query B {\\n b\\n }\\n"): (typeof documents)["\\n query B {\\n b\\n }\\n"]; + export function graphql(source: "\\n fragment C on Query {\\n c\\n }\\n"): (typeof documents)["\\n fragment C on Query {\\n c\\n }\\n"]; + + export function graphql(source: string): unknown; + export function graphql(source: string) { + return (documents as any)[source] ?? {}; + } + + export type DocumentType> = TDocumentNode extends DocumentNode< infer TType, any> ? TType : never;" + `); + }); + + it('fragmentMasking: {}', async () => { + const result = await executeCodegen({ + schema: [ + /* GraphQL */ ` + type Query { + a: String + b: String + c: String + } + `, + ], + documents: path.join(__dirname, 'fixtures/simple-uppercase-operation-name.ts'), + generates: { + out1: { + preset, + plugins: [], + presetConfig: { + fragmentMasking: {}, + }, + }, + }, + }); + + expect(result).toHaveLength(4); + }); + + it('fragmentMasking.unmaskFunctionName', async () => { + const result = await executeCodegen({ + schema: [ + /* GraphQL */ ` + type Query { + a: String + b: String + c: String + } + `, + ], + documents: path.join(__dirname, 'fixtures/simple-uppercase-operation-name.ts'), + generates: { + out1: { + preset, + plugins: [], + presetConfig: { + fragmentMasking: { + unmaskFunctionName: 'iLikeTurtles', + }, + }, + }, + }, + }); + + expect(result).toHaveLength(4); + const gqlFile = result.find(file => file.filename === 'out1/fragment-masking.ts'); + expect(gqlFile.content).toMatchInlineSnapshot(` + "import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; + + + export type FragmentType> = TDocumentType extends DocumentNode< + infer TType, + any + > + ? TType extends { ' $fragmentName': infer TKey } + ? TKey extends string + ? { ' $fragmentRefs': { [key in TKey]: TType } } + : never + : never + : never; + + // return non-nullable if \`fragmentType\` is non-nullable + export function iLikeTurtles( + _documentNode: DocumentNode, + fragmentType: FragmentType> + ): TType; + // return nullable if \`fragmentType\` is nullable + export function iLikeTurtles( + _documentNode: DocumentNode, + fragmentType: FragmentType> | null | undefined + ): TType | null | undefined; + // return array of non-nullable if \`fragmentType\` is array of non-nullable + export function iLikeTurtles( + _documentNode: DocumentNode, + fragmentType: ReadonlyArray>> + ): ReadonlyArray; + // return array of nullable if \`fragmentType\` is array of nullable + export function iLikeTurtles( + _documentNode: DocumentNode, + fragmentType: ReadonlyArray>> | null | undefined + ): ReadonlyArray | null | undefined + export function iLikeTurtles( + _documentNode: DocumentNode, + fragmentType: FragmentType> | ReadonlyArray>> | null | undefined + ): TType | ReadonlyArray | null | undefined { + return fragmentType as any + } + " + `); + + expect(gqlFile.content).toBeSimilarStringTo(` + export function iLikeTurtles( + _documentNode: DocumentNode, + fragmentType: FragmentType> + ): TType; + `); + expect(gqlFile.content).toBeSimilarStringTo(` + export function iLikeTurtles( + _documentNode: DocumentNode, + fragmentType: FragmentType> | null | undefined + ): TType | null | undefined; + `); + expect(gqlFile.content).toBeSimilarStringTo(` + export function iLikeTurtles( + _documentNode: DocumentNode, + fragmentType: ReadonlyArray>> + ): ReadonlyArray; + `); + expect(gqlFile.content).toBeSimilarStringTo(` + export function iLikeTurtles( + _documentNode: DocumentNode, + fragmentType: ReadonlyArray>> | null | undefined + ): ReadonlyArray | null | undefined + `); + expect(gqlFile.content).toBeSimilarStringTo(` + export function iLikeTurtles( + _documentNode: DocumentNode, + fragmentType: FragmentType> | ReadonlyArray>> | null | undefined + ): TType | ReadonlyArray | null | undefined { + return fragmentType as any + } + `); + }); + + it('can accept null in useFragment', async () => { + const docPath = path.join(__dirname, 'fixtures/with-fragment.ts'); + const result = await executeCodegen({ + schema: [ + /* GraphQL */ ` + type Query { + foo: Foo + foos: [Foo] + } + + type Foo { + value: String + } + `, + ], + documents: docPath, + generates: { + out1: { + preset, + plugins: [], + presetConfig: { + fragmentMasking: true, + }, + }, + }, + }); + + const content = mergeOutputs([ + ...result, + readFileSync(docPath, 'utf8'), + ` + function App(props: { data: FooQuery }) { + const fragment: FooFragment | null | undefined = useFragment(Fragment, props.data.foo); + return fragment == null ? "no data" : fragment.value; + } + `, + ]); + validateTs(content, undefined, false, true, [`Duplicate identifier 'DocumentNode'.`], true); + }); + + it('can accept list in useFragment', async () => { + const docPath = path.join(__dirname, 'fixtures/with-fragment.ts'); + const result = await executeCodegen({ + schema: [ + /* GraphQL */ ` + type Query { + foo: Foo + foos: [Foo!] + } + + type Foo { + value: String + } + `, + ], + documents: docPath, + generates: { + out1: { + preset, + plugins: [], + presetConfig: { + fragmentMasking: true, + }, + }, + }, + }); + + const content = mergeOutputs([ + ...result, + readFileSync(docPath, 'utf8'), + ` + function App(props: { data: FoosQuery }) { + const fragments: ReadonlyArray | null | undefined = useFragment(Fragment, props.data.foos); + return fragments == null ? "no data" : fragments.map(f => f.value); + } + `, + ]); + validateTs(content, undefined, false, true, [`Duplicate identifier 'DocumentNode'.`], true); + }); + }); + + it('generates correct named imports for ESM', async () => { + const result = await executeCodegen({ + schema: [ + /* GraphQL */ ` + type Query { + a: String + b: String + c: String + } + `, + ], + documents: path.join(__dirname, 'fixtures/simple-uppercase-operation-name.ts'), + generates: { + out1: { + preset, + plugins: [], + }, + }, + emitLegacyCommonJSImports: false, + }); + + expect(result).toHaveLength(4); + // index.ts (re-exports) + const indexFile = result.find(file => file.filename === 'out1/index.ts'); + expect(indexFile.content).toEqual(`export * from "./gql.js" +export * from "./fragment-masking.js"`); + + // gql.ts + const gqlFile = result.find(file => file.filename === 'out1/gql.ts'); + expect(gqlFile.content).toMatchInlineSnapshot(` + "/* eslint-disable */ + import * as types from './graphql.js'; + import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; + + const documents = { + "\\n query A {\\n a\\n }\\n": types.ADocument, + "\\n query B {\\n b\\n }\\n": types.BDocument, + "\\n fragment C on Query {\\n c\\n }\\n": types.CFragmentDoc, + }; + + export function graphql(source: "\\n query A {\\n a\\n }\\n"): (typeof documents)["\\n query A {\\n a\\n }\\n"]; + export function graphql(source: "\\n query B {\\n b\\n }\\n"): (typeof documents)["\\n query B {\\n b\\n }\\n"]; + export function graphql(source: "\\n fragment C on Query {\\n c\\n }\\n"): (typeof documents)["\\n fragment C on Query {\\n c\\n }\\n"]; + + export function graphql(source: string): unknown; + export function graphql(source: string) { + return (documents as any)[source] ?? {}; + } + + export type DocumentType> = TDocumentNode extends DocumentNode< infer TType, any> ? TType : never;" + `); + + // graphql.ts + const graphqlFile = result.find(file => file.filename === 'out1/gql.ts'); + expect(graphqlFile).toBeDefined(); + }); + + describe('when no operations are found', () => { + it('still generates the helper `graphql()` (or under another `presetConfig.gqlTagName` name) function', async () => { + const result = await executeCodegen({ + schema: [ + /* GraphQL */ ` + type Query { + a: String + b: String + c: String + } + `, + ], + generates: { + out1: { + preset, + plugins: [], + }, + }, + emitLegacyCommonJSImports: false, + }); + + expect(result).toHaveLength(4); + // index.ts (re-exports) + const indexFile = result.find(file => file.filename === 'out1/index.ts'); + expect(indexFile.content).toEqual(`export * from "./gql.js" +export * from "./fragment-masking.js"`); + + // gql.ts + const gqlFile = result.find(file => file.filename === 'out1/gql.ts'); + expect(gqlFile.content).toMatchInlineSnapshot(` + "/* eslint-disable */ + import * as types from './graphql.js'; + import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; + + + export function graphql(source: string): unknown; + export function graphql(source: string) { + return (documents as any)[source] ?? {}; + } + + export type DocumentType> = TDocumentNode extends DocumentNode< infer TType, any> ? TType : never;" + `); + + // graphql.ts + const graphqlFile = result.find(file => file.filename === 'out1/gql.ts'); + expect(graphqlFile).toBeDefined(); + }); + }); +}); diff --git a/packages/presets/client/tests/fixtures/crlf-operation.ts b/packages/presets/client/tests/fixtures/crlf-operation.ts new file mode 100644 index 00000000000..085ce59f581 --- /dev/null +++ b/packages/presets/client/tests/fixtures/crlf-operation.ts @@ -0,0 +1,24 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +//@ts-ignore +import gql from 'gql-tag'; + +//@ts-ignore +const A = gql(/* GraphQL */ ` + query a { + a + } +`); + +//@ts-ignore +const B = gql(/* GraphQL */ ` + query b { + b + } +`); + +//@ts-ignore +const C = gql(/* GraphQL */ ` + fragment C on Query { + c + } +`); diff --git a/packages/presets/client/tests/fixtures/duplicate-operation.ts b/packages/presets/client/tests/fixtures/duplicate-operation.ts new file mode 100644 index 00000000000..f99b6d261b2 --- /dev/null +++ b/packages/presets/client/tests/fixtures/duplicate-operation.ts @@ -0,0 +1,17 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +//@ts-ignore +import gql from 'gql'; + +//@ts-ignore +const A1 = gql` + query a { + a + } +`; + +//@ts-ignore +const A2 = gql` + query a { + a + } +`; diff --git a/packages/presets/client/tests/fixtures/simple-lowercase-operation-name.ts b/packages/presets/client/tests/fixtures/simple-lowercase-operation-name.ts new file mode 100644 index 00000000000..085ce59f581 --- /dev/null +++ b/packages/presets/client/tests/fixtures/simple-lowercase-operation-name.ts @@ -0,0 +1,24 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +//@ts-ignore +import gql from 'gql-tag'; + +//@ts-ignore +const A = gql(/* GraphQL */ ` + query a { + a + } +`); + +//@ts-ignore +const B = gql(/* GraphQL */ ` + query b { + b + } +`); + +//@ts-ignore +const C = gql(/* GraphQL */ ` + fragment C on Query { + c + } +`); diff --git a/packages/presets/client/tests/fixtures/simple-uppercase-operation-name.ts b/packages/presets/client/tests/fixtures/simple-uppercase-operation-name.ts new file mode 100644 index 00000000000..8dc3266eace --- /dev/null +++ b/packages/presets/client/tests/fixtures/simple-uppercase-operation-name.ts @@ -0,0 +1,24 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +//@ts-ignore +import gql from 'gql-tag'; + +//@ts-ignore +const A = gql(/* GraphQL */ ` + query A { + a + } +`); + +//@ts-ignore +const B = gql(/* GraphQL */ ` + query B { + b + } +`); + +//@ts-ignore +const C = gql(/* GraphQL */ ` + fragment C on Query { + c + } +`); diff --git a/packages/presets/client/tests/fixtures/union-fragment.ts b/packages/presets/client/tests/fixtures/union-fragment.ts new file mode 100644 index 00000000000..c50d81abb0a --- /dev/null +++ b/packages/presets/client/tests/fixtures/union-fragment.ts @@ -0,0 +1,23 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ + +//@ts-ignore +const Query = gql(/* GraphQL */ ` + query Foo { + foo { + ...Foo + } + } +`); + +//@ts-ignore +const Fragment = gql(/* GraphQL */ ` + fragment Foo on Foo { + __typename + ... on Bar { + stringValue + } + ... on Baz { + intValue + } + } +`); diff --git a/packages/presets/client/tests/fixtures/with-fragment.ts b/packages/presets/client/tests/fixtures/with-fragment.ts new file mode 100644 index 00000000000..38738d5c16c --- /dev/null +++ b/packages/presets/client/tests/fixtures/with-fragment.ts @@ -0,0 +1,26 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ + +//@ts-ignore +const Query = gql(/* GraphQL */ ` + query Foo { + foo { + ...Foo + } + } +`); + +//@ts-ignore +const LsitQuery = gql(/* GraphQL */ ` + query Foos { + foos { + ...Foo + } + } +`); + +//@ts-ignore +const Fragment = gql(/* GraphQL */ ` + fragment Foo on Foo { + value + } +`); diff --git a/packages/presets/gql-tag-operations/src/babel.ts b/packages/presets/gql-tag-operations/src/babel.ts index bd393548853..c72c138d0d9 100644 --- a/packages/presets/gql-tag-operations/src/babel.ts +++ b/packages/presets/gql-tag-operations/src/babel.ts @@ -13,6 +13,7 @@ export default declare((api, opts): PluginObj => { const visitor = new ClientSideBaseVisitor(noopSchema, [], {}, {}); const artifactDirectory = opts['artifactDirectory'] ?? ''; + const gqlTagName = opts['gqlTagName'] || 'gql'; let program: NodePath; return { @@ -22,7 +23,7 @@ export default declare((api, opts): PluginObj => { program = path; }, CallExpression(path, state) { - if (path.node.callee.type !== 'Identifier' || path.node.callee.name !== 'gql') { + if (path.node.callee.type !== 'Identifier' || path.node.callee.name !== gqlTagName) { return; } const [argument] = path.node.arguments; diff --git a/packages/presets/gql-tag-operations/src/index.ts b/packages/presets/gql-tag-operations/src/index.ts index 4ebb2927027..6a76f433a3f 100644 --- a/packages/presets/gql-tag-operations/src/index.ts +++ b/packages/presets/gql-tag-operations/src/index.ts @@ -61,10 +61,29 @@ export type GqlTagConfig = { * ``` */ fragmentMasking?: FragmentMaskingConfig | boolean; + /** + * @description Specify the name of the "graphql tag" function to use + * @default "gql" + * + * E.g. `graphql` or `gql`. + * + * @exampleMarkdown + * ```yaml {5} + * generates: + * gql/: + * preset: gql-tag-operations-preset + * presetConfig: + * gqlTagName: 'graphql' + * ``` + */ + gqlTagName?: string; }; export const preset: Types.OutputPreset = { buildGeneratesSection: options => { + // TODO: add link? + // eslint-disable-next-line no-console + console.warn('DEPRECATED: `gql-tag-operations-preset` is deprecated in favor of `client-preset`.'); /** when not using augmentation stuff must be re-exported. */ const reexports: Array = []; @@ -190,6 +209,7 @@ export const preset: Types.OutputPreset = { config: { ...config, augmentedModuleName: options.presetConfig.augmentedModuleName, + gqlTagName: options.presetConfig.gqlTagName || 'gql', }, documents: sources, }, diff --git a/packages/presets/gql-tag-operations/tests/gql-tag-operations.spec.ts b/packages/presets/gql-tag-operations/tests/gql-tag-operations.spec.ts index ea59e9fd4c9..01db9223dff 100644 --- a/packages/presets/gql-tag-operations/tests/gql-tag-operations.spec.ts +++ b/packages/presets/gql-tag-operations/tests/gql-tag-operations.spec.ts @@ -36,13 +36,13 @@ describe('gql-tag-operations-preset', () => { const gqlFile = result.find(file => file.filename === 'out1/gql.ts'); expect(gqlFile.content).toMatchInlineSnapshot(` "/* eslint-disable */ - import * as graphql from './graphql'; + import * as types from './graphql'; import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; const documents = { - "\\n query A {\\n a\\n }\\n": graphql.ADocument, - "\\n query B {\\n b\\n }\\n": graphql.BDocument, - "\\n fragment C on Query {\\n c\\n }\\n": graphql.CFragmentDoc, + "\\n query A {\\n a\\n }\\n": types.ADocument, + "\\n query B {\\n b\\n }\\n": types.BDocument, + "\\n fragment C on Query {\\n c\\n }\\n": types.CFragmentDoc, }; export function gql(source: "\\n query A {\\n a\\n }\\n"): (typeof documents)["\\n query A {\\n a\\n }\\n"]; @@ -91,13 +91,13 @@ describe('gql-tag-operations-preset', () => { const gqlFile = result.find(file => file.filename === 'out1/gql.ts'); expect(gqlFile.content).toMatchInlineSnapshot(` "/* eslint-disable */ - import * as graphql from './graphql'; + import * as types from './graphql'; import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; const documents = { - "\\n query a {\\n a\\n }\\n": graphql.ADocument, - "\\n query b {\\n b\\n }\\n": graphql.BDocument, - "\\n fragment C on Query {\\n c\\n }\\n": graphql.CFragmentDoc, + "\\n query a {\\n a\\n }\\n": types.ADocument, + "\\n query b {\\n b\\n }\\n": types.BDocument, + "\\n fragment C on Query {\\n c\\n }\\n": types.CFragmentDoc, }; export function gql(source: "\\n query a {\\n a\\n }\\n"): (typeof documents)["\\n query a {\\n a\\n }\\n"]; @@ -139,13 +139,13 @@ describe('gql-tag-operations-preset', () => { const gqlFile = result.find(file => file.filename === 'out1/gql.ts'); expect(gqlFile.content).toMatchInlineSnapshot(` "/* eslint-disable */ - import * as graphql from './graphql'; + import * as types from './graphql'; import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; const documents = { - "\\n query a {\\n a\\n }\\n": graphql.ADocument, - "\\n query b {\\n b\\n }\\n": graphql.BDocument, - "\\n fragment C on Query {\\n c\\n }\\n": graphql.CFragmentDoc, + "\\n query a {\\n a\\n }\\n": types.ADocument, + "\\n query b {\\n b\\n }\\n": types.BDocument, + "\\n fragment C on Query {\\n c\\n }\\n": types.CFragmentDoc, }; export function gql(source: "\\n query a {\\n a\\n }\\n"): (typeof documents)["\\n query a {\\n a\\n }\\n"]; @@ -188,13 +188,13 @@ describe('gql-tag-operations-preset', () => { const gqlFile = result.find(file => file.filename === 'out1/gql.ts'); expect(gqlFile.content).toMatchInlineSnapshot(` "/* eslint-disable */ - import * as graphql from './graphql'; + import * as types from './graphql'; import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; const documents = { - "\\n query A {\\n a\\n }\\n": graphql.ADocument, - "\\n query B {\\n b\\n }\\n": graphql.BDocument, - "\\n fragment C on Query {\\n c\\n }\\n": graphql.CFragmentDoc, + "\\n query A {\\n a\\n }\\n": types.ADocument, + "\\n query B {\\n b\\n }\\n": types.BDocument, + "\\n fragment C on Query {\\n c\\n }\\n": types.CFragmentDoc, }; export function gql(source: "\\n query A {\\n a\\n }\\n"): (typeof documents)["\\n query A {\\n a\\n }\\n"]; @@ -283,11 +283,11 @@ describe('gql-tag-operations-preset', () => { const gqlFile = result.find(file => file.filename === 'out1/gql.ts'); expect(gqlFile.content).toMatchInlineSnapshot(` "/* eslint-disable */ - import * as graphql from './graphql'; + import * as types from './graphql'; import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; const documents = { - "\\n query a {\\n a\\n }\\n": graphql.ADocument, + "\\n query a {\\n a\\n }\\n": types.ADocument, }; export function gql(source: "\\n query a {\\n a\\n }\\n"): (typeof documents)["\\n query a {\\n a\\n }\\n"]; @@ -727,13 +727,13 @@ describe('gql-tag-operations-preset', () => { const gqlFile = result.find(file => file.filename === 'out1/gql.ts'); expect(gqlFile.content).toMatchInlineSnapshot(` "/* eslint-disable */ - import * as graphql from './graphql.js'; + import * as types from './graphql.js'; import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; const documents = { - "\\n query A {\\n a\\n }\\n": graphql.ADocument, - "\\n query B {\\n b\\n }\\n": graphql.BDocument, - "\\n fragment C on Query {\\n c\\n }\\n": graphql.CFragmentDoc, + "\\n query A {\\n a\\n }\\n": types.ADocument, + "\\n query B {\\n b\\n }\\n": types.BDocument, + "\\n fragment C on Query {\\n c\\n }\\n": types.CFragmentDoc, }; export function gql(source: "\\n query A {\\n a\\n }\\n"): (typeof documents)["\\n query A {\\n a\\n }\\n"]; diff --git a/website/next.config.mjs b/website/next.config.mjs index e7333059de8..d81eb745e32 100644 --- a/website/next.config.mjs +++ b/website/next.config.mjs @@ -79,6 +79,8 @@ export default withGuildDocs({ '/docs/getting-started/require-field': '/docs/config-reference/require-field', '/docs/getting-started/naming-convention': '/docs/config-reference/naming-convention', '/docs/getting-started/how-does-it-work': '/docs/advanced/how-does-it-work', + '/docs/guides/react': '/docs/advanced/react-vue', + '/docs/guides/vue': '/docs/advanced/react-vue', '/plugins/typescript-svelte-urql': '/plugins', '/plugins/presets': '/plugins', '/docs/getting-startedinstallation': '/docs/getting-started', diff --git a/website/src/pages/docs/getting-started/development-workflow.mdx b/website/src/pages/docs/getting-started/development-workflow.mdx index 1e145c04c2c..63ef5a1f619 100644 --- a/website/src/pages/docs/getting-started/development-workflow.mdx +++ b/website/src/pages/docs/getting-started/development-workflow.mdx @@ -86,3 +86,15 @@ const config: CodegenConfig = { export default config ``` + +## What's next? + +Get started with our guides: + +- [React and Vue](/docs/guides/react-vue) +- [Angular](/docs/guides/angular) +- [Svelte](/docs/guides/svelte) +- [Apollo and Yoga server](/docs/guides/graphql-server-apollo-yoga) +- [GraphQL Modules](/docs/guides/graphql-modules) + +If your stack is not listed above, please refer to [our plugins directory](/plugins). diff --git a/website/src/pages/docs/getting-started/index.mdx b/website/src/pages/docs/getting-started/index.mdx index a236deeda22..0f6d056e658 100644 --- a/website/src/pages/docs/getting-started/index.mdx +++ b/website/src/pages/docs/getting-started/index.mdx @@ -2,7 +2,7 @@ title: Introduction --- -import { Tabs, Tab, Callout } from '@theguild/components' +import { Tabs, Tab, Callout, PackageCmd } from '@theguild/components' # Introduction to GraphQL Code Generator @@ -255,16 +255,48 @@ Any mistake on your manually maintained data types ripples in many of your compo For this reason, automating and generating the typing of your GraphQL operations will both improve the developer experience and stability of your stack. -After [installing](/docs/getting-started/installation) and [configuring](/docs/config-reference/codegen-config) GraphQL Code Generator, our front-end code will be fully-typed and -up-to-date as follows: +After installing 3 packages: + + + +and providing a simple configuration: + +```ts filename="codegen.ts" +const config = { + schema: 'https://localhost:4000/graphql', + documents: ['src/**/*.tsx'] + generates: { + './src/gql/': { preset: 'client' } + } +} +export default config +``` + +You will no longer need to maintain TypeScript types: ```tsx import { useQuery } from 'urql' -import { postsQueryDocument } from './graphql/generated' +import { graphql } from './graphql/gql' + +// postsQueryDocument is now fully typed! +const postsQueryDocument = graphql(/* GraphQL */ ` + query Posts { + posts { + id + title + author { + id + firstName + lastName + } + } + } +`) const Posts = () => { + // URQL's `useQuery()` knows how to work with typed graphql documents const [result] = useQuery({ query: postsQueryDocument }) // `result` is fully typed! @@ -275,12 +307,29 @@ const Posts = () => { ```tsx -import { usePosts } from '../graphql/generated' +import { useQuery } from '@apollo/client'; +import { graphql } from './graphql/gql' + +// postsQueryDocument is now fully typed! +const postsQueryDocument = graphql(/* GraphQL */ ` + query Posts { + posts { + id + title + author { + id + firstName + lastName + } + } + } +`) const Posts = () => { - const { data } = usePosts() + // Apollo's `useQuery()` knows how to work with typed graphql documents + const [result] = useQuery({ query: postsQueryDocument }) - // `data` is typed! + // `result` is fully typed! // … } ``` @@ -288,27 +337,36 @@ const Posts = () => { ```vue - - - + + ``` @@ -452,9 +510,14 @@ export const resolvers: UsersModule.Resolvers = { ## What's next? -Start by [installing GraphQL Code Generator](/docs/getting-started/installation) in your project. +Start by [installing GraphQL Code Generator](/docs/getting-started/installation) in your project or get started with our guides: +- [React and Vue](/docs/guides/react-vue) +- [Angular](/docs/guides/angular) +- [Svelte](/docs/guides/svelte) +- [Apollo and Yoga server](/docs/guides/graphql-server-apollo-yoga) +- [GraphQL Modules](/docs/guides/graphql-modules) -Then, you can either read a guide or go over [the list of available plugins](/plugins) to find more plugins that satisfy your needs. +If your stack is not listed above, please refer to [our plugins directory](/plugins). If you are experiencing any issues, you can reach us via the following channels: diff --git a/website/src/pages/docs/getting-started/installation.mdx b/website/src/pages/docs/getting-started/installation.mdx index ad87e3cbeb5..e2a7c8fb7a8 100644 --- a/website/src/pages/docs/getting-started/installation.mdx +++ b/website/src/pages/docs/getting-started/installation.mdx @@ -4,7 +4,7 @@ import { Callout, PackageCmd } from '@theguild/components' Make sure that you add both the `graphql` and `@graphql-codegen/cli` packages in your project's dependencies: - + ## Global Installation @@ -35,7 +35,7 @@ Once installed, GraphQL Code Generator CLI can help you configure your project b @@ -50,3 +50,15 @@ If you are looking for the **best way to leverage GraphQL Code Generator on your On top of each plugin documentation, we provide one Guide for the most famous framework such as [React](/docs/guides/react) or [Apollo Server](/docs/guides/graphql-server-apollo-yoga). Each guide exposes the best plugins and configurations available for each framework and stack (React with Apollo / URQL / React Query, Angular with Apollo, …). Otherwise, if you **prefer exploring plugins and skipping the high-level explanations**, the go-to resource will be the [plugins documentation](/plugins) and the [`codegen.yaml` API reference documentation](/docs/config-reference/codegen-config). + +## What's next? + +Get started with our guides: + +- [React and Vue](/docs/guides/react-vue) +- [Angular](/docs/guides/angular) +- [Svelte](/docs/guides/svelte) +- [Apollo and Yoga server](/docs/guides/graphql-server-apollo-yoga) +- [GraphQL Modules](/docs/guides/graphql-modules) + +If your stack is not listed above, please refer to [our plugins directory](/plugins). diff --git a/website/src/pages/docs/guides/front-end-typescript-only.mdx b/website/src/pages/docs/guides/front-end-typescript-only.mdx deleted file mode 100644 index 7f8aabefb3e..00000000000 --- a/website/src/pages/docs/guides/front-end-typescript-only.mdx +++ /dev/null @@ -1,107 +0,0 @@ -import { Callout, PackageCmd } from '@theguild/components' - -# Guide: TypeScript-only for front-end - -Even while using another GraphQL Client (not listed in our guides) or willing only to use generated TypeScript types, GraphQL Code Generator still got you covered! - -`@graphql-codegen/typescript-operations` is the perfect plugin if you prefer only manipulating generated TypeScript types. - -Given the following example: - -```tsx -import gql from 'graphql-tag' - -const postsQueryDocument = gql` - query Posts { - posts { - id - title - author { - id - firstName - lastName - } - } - } -` - -const Posts = () => { - const { data } = useCustomFetchGraphQLData(postsQueryDocument) -} -``` - -Using `@graphql-codegen/typescript-operations` would generate the TypeScript type definitions for only used Query, Mutation, Subscription and Fragment. - -Just a few configuration steps are required to get those TypeScript types generated: - -### Install - - - -### Configure the plugin - -Create or update your `codegen.yaml` file as follows: - -```yaml -schema: http://my-graphql-api.com/graphql -documents: './src/**/*.tsx' -generates: - graphql/generated.ts: - plugins: - - typescript - - typescript-operations -``` - - -**`schema` and `documents` values** - -`schema` needs to be your target GraphQL API URL (`"/graphql"` included). - -`documents` is a glob expression to your `.graphql`, `.ts` or `.tsx` files. - - - -### Run the codegen and update your code - -Assuming that, as recommended, your `package.json` has the following script: - -```json filename="package.json" -{ - "scripts": { - "generate": "graphql-codegen" - } -} -``` - -Running the following generates the `graphql/generated.tsx` file. - - - -We can now use the generated types as follows: - -```tsx -import gql from 'graphql-tag' -import { PostsQuery } from '../graphql/generated.tsx' - -const postsQueryDocument = gql` - query { - posts { - id - title - author { - id - firstName - lastName - } - } - } -` - -const Posts = () => { - const { data } = useCustomFetchGraphQLData(postsQueryDocument) - - const result = data as PostsQuery -} -``` - -For more information on the `@graphql-codegen/typescript-operations` plugin configuration, please refer to its [documentation](/plugins/typescript-operations). diff --git a/website/src/pages/docs/guides/meta.json b/website/src/pages/docs/guides/meta.json index 206971d5a69..e7b2222aff5 100644 --- a/website/src/pages/docs/guides/meta.json +++ b/website/src/pages/docs/guides/meta.json @@ -1,9 +1,7 @@ { - "react": "React", - "vue": "Vue.js", + "react-vue": "React, Vue", "angular": "Angular", "svelte": "Svelte / Kit", - "front-end-typescript-only": "TypeScript only (front-end)", "flutter-freezed": "Dart/Flutter", "graphql-server-apollo-yoga": "Apollo Server / GraphQL Yoga", "graphql-modules": "GraphQL Modules", diff --git a/website/src/pages/docs/guides/react-vue.mdx b/website/src/pages/docs/guides/react-vue.mdx new file mode 100644 index 00000000000..f483901382c --- /dev/null +++ b/website/src/pages/docs/guides/react-vue.mdx @@ -0,0 +1,551 @@ +import { Tabs, Tab, Callout, PackageCmd } from '@theguild/components' + +# Guide: React and Vue + +GraphQL Code Generator provides an unified way to get TypeScript types from GraphQL operations for [most modern GraphQL clients and frameworks](#appendix-ii-compatibility). + +
+ +This guide is built using the [Star wars films demo API](https://swapi-graphql.netlify.app/.netlify/functions/index), you can [find the complete `schema.graphql` in our repository `examples/front-end` folder](https://github.com/dotansimha/graphql-code-generator/blob/master/examples/front-end/schema.graphql). + +We will build a simple GraphQL front-end app using the following Query to fetches the list of Star Wars films: + +```graphql +query allFilmsWithVariablesQuery($first: Int!) { + allFilms(first: $first) { + edges { + node { + ...FilmItem + } + } + } +} +``` + +and its `FilmItem` Fragment definition: + +```graphql +fragment FilmItem on Film { + id + title + releaseDate + producers +} +``` + + + +All the below code examples are available in our [repository `examples/front-end` folder](https://github.com/dotansimha/graphql-code-generator/blob/master/examples/front-end/schema.graphql). + + + +## Installation + +For most GraphQL clients and frameworks (React, Vue), install the following packages: + + + +Then provide the corresponding framework-specific configuration: + + + + + +```ts filename="codegen.ts" {5} +import { CodegenConfig } from '@graphql-codegen/cli' + +const config: CodegenConfig = { + schema: 'https://swapi-graphql.netlify.app/.netlify/functions/index', + documents: ['src/**/*.tsx'], + generates: { + './src/gql/': { + preset: 'client', + plugins: [] + } + } +} + +export default config +``` + + + + + +```ts filename="codegen.ts" {1, 5, 9-11} +import type { CodegenConfig } from '@graphql-codegen/cli' + +const config: CodegenConfig = { + schema: 'https://swapi-graphql.netlify.app/.netlify/functions/index', + documents: ['src/**/*.vue'], + generates: { + './src/gql/': { + preset: 'client', + config: { + useTypeImports: true + }, + plugins: [] + } + } +} + +export default config +``` + + + + + +> Each framework-specific lines are highlighted + +
+ + + +**Usage with `@tanstack/react-query`** + +For the best developer experience, we recommend using `@tanstack/react-query` with `graphql-request@^5.0.0`.
+If you are willing to provide your own fetcher, you can directly jump to the ["Appendix I: Compatibility"](#appendix-i-react-query-with-a-custom-fetcher-setup) and continue the guide once React Query is properly setup. + +
+ +
+ +## Writing GraphQL Queries + +First, start GraphQL Code Generator in watch mode: + +``` +yarn graphql-codegen --watch +``` + +_Using GraphQL Code Generator will type your GraphQL Query and Mutations as you write them ⚡️_ + +Now, we can start implementing our first query with the `graphql()` function, generated in `src/gql/`: + + + + + +```ts filename="src/App.tsx" {6, 8-18, 22} +import React from 'react' +import { useQuery } from '@apollo/client' + +import './App.css' +import Film from './Film' +import { graphql } from '../src/gql' + +const allFilmsWithVariablesQueryDocument = graphql(/* GraphQL */ ` + query allFilmsWithVariablesQuery($first: Int!) { + allFilms(first: $first) { + edges { + node { + ...FilmItem + } + } + } + } +`) + +function App() { + // `data` is typed! + const { data } = useQuery(allFilmsWithVariablesQueryDocument, { variables: { first: 10 } }) + return ( +
+ {data &&
    {data.allFilms?.edges?.map((e, i) => e?.node && )}
} +
+ ) +} + +export default App +``` + +
+ + + +```ts filename="src/App.tsx" {7, 8-18, 23-27} +import React from 'react' +import request from 'graphql-request' +import { useQuery } from '@tanstack/react-query' + +import './App.css' +import Film from './Film' +import { graphql } from '../src/gql' + +const allFilmsWithVariablesQueryDocument = graphql(/* GraphQL */ ` + query allFilmsWithVariablesQuery($first: Int!) { + allFilms(first: $first) { + edges { + node { + ...FilmItem + } + } + } + } +`) + +function App() { + // `data` is typed! + const { data } = useQuery(['films'], async () => + request('https://swapi-graphql.netlify.app/.netlify/functions/index', allFilmsWithVariablesQueryDocument, { + first: 10 // variables are typed too! + }) + ) + + return ( +
+ {data &&
    {data.allFilms?.edges?.map((e, i) => e?.node && )}
} +
+ ) +} + +export default App +``` + +
+ + + +```ts filename="src/App.tsx" {6, 8-18, 22-27} +import React from 'react' +import { useQuery } from 'urql' + +import './App.css' +import Film from './Film' +import { graphql } from '../src/gql' + +const allFilmsWithVariablesQueryDocument = graphql(/* GraphQL */ ` + query allFilmsWithVariablesQuery($first: Int!) { + allFilms(first: $first) { + edges { + node { + ...FilmItem + } + } + } + } +`) + +function App() { + // `data` is typed! + const [{ data }] = useQuery({ + query: allFilmsWithVariablesQueryDocument, + variables: { + // variables are typed too! + first: 10 + } + }) + + return ( +
+ {data &&
    {data.allFilms?.edges?.map((e, i) => e?.node && )}
} +
+ ) +} + +export default App +``` + +
+ + + +```vue filename="src/App.vue" {6, 9-21, 24} + + + +``` + + + + + +```vue filename="src/App.vue" {6, 9-21, 24} + + + +``` + + + +
+ + + +Examples for SWR (React), `graphql-request` and Villus (Vue) are available in our [repository `examples/front-end` folder](https://github.com/dotansimha/graphql-code-generator/blob/master/examples/front-end/schema.graphql). + + + +Simply use the provided `graphql()` function (from `../src/gql/`) to define your GraphQL Query or Mutation, then, get instantly typed-variables and result just by passing your GraphQL documentto your favorite client ✨ + +Let's now take a look at how to define our `` component using the `FilmItem` fragment and its corresponding TypeScript type. + +## Writing GraphQL Fragments + +As showcased, in more details, [in one of our recent blog post](https://www.the-guild.dev/blog/unleash-the-power-of-fragments-with-graphql-codegen), using GraphQL Fragments helps building better isolated and reusable UI components. + +Let's look at the implementation of our `Film` UI component in React or Vue: + + + + + +```ts filename="src/Film.tsx" {4-11, 15, 17} +import { FragmentType, useFragment } from './gql/fragment-masking' +import { graphql } from '../src/gql' + +export const FilmFragment = graphql(/* GraphQL */ ` + fragment FilmItem on Film { + id + title + releaseDate + producers + } +`) + +const Film = (props: { + /* `film` property has the correct type 🎉 */ + film: FragmentType +}) => { + const film = useFragment(FilmFragment, props.film) + return ( +
+

{film.title}

+

{film.releaseDate}

+
+ ) +} + +export default Film +``` + +
+ + + +```vue filename="src/components/FilmItem.vue" {5-12, 14, 17} + + + +``` + + + +
+ + + +Examples for SWR (React), `graphql-request` and Villus (Vue) are available in our [repository `examples/front-end` folder](https://github.com/dotansimha/graphql-code-generator/blob/master/examples/front-end/schema.graphql). + + + +You will notice that our `` component leverages 2 imports from our generated code (from `../src/gql`): the `FragmentType` type helper and the `useFragment()` function. + +- we use `FragmentType` to get the corresponding Fragment TypeScript type +- later on, we use `useFragment()` to retrieve the properly `film` property + +Leveraging `FragmentType` and `useFragment()` helps keeping your UI component isolated and not inherit of the typings of the parent GraphQL Query. + +
+ +> By using GraphQL Fragments, you are explicitly declaring the data dependencies of your UI component and safely accessing only the data it needs. + +
+ +Finally, unlike most GraphQL Client setup, you don't need to append the Fragment definition document to the related Query, you simply need to reference it in your GraphQL Query, as shown below: + +```ts {6} +const allFilmsWithVariablesQueryDocument = graphql(/* GraphQL */ ` + query allFilmsWithVariablesQuery($first: Int!) { + allFilms(first: $first) { + edges { + node { + ...FilmItem + } + } + } + } +`) +``` + +
+ +Congratulations, you now have the best GraphQL front-end experience with fully-typed Queries and Mutations! + +From simple Queries to more advanced Fragments-based ones, GraphQL Code Generator got you covered with a simple TypeScript configuration file and, without impact on your application bundle size! 🚀 + +**What's next?** + +To get the best GraphQL development experience, we recommend to install the [GraphQL VSCode extension](https://marketplace.visualstudio.com/items?itemName=GraphQL.vscode-graphql) to get: + +- syntax highlighting +- autocomplete suggestions +- validation against schema +- snippets +- go to definition for fragments and input types + +Also, make sure to follow the GraphQL best practices by using [`graphql-eslint`](https://github.com/B2o5T/graphql-eslint) and the [ESLint VSCode extension](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) to visualize errors and warnings inlined in your code correctly. + +Feel free to continue playing with this demo project, available in all flavors, in our [repository `examples/front-end` folder](https://github.com/dotansimha/graphql-code-generator/blob/master/examples/front-end/). + +
+ +--- + +
+ +## Appendix I: React Query with a custom fetcher setup + +The use of `@tanstack/react-query` along with `graphql-request@^5` is highly recommended due to GraphQL Code Generator integration with `graphql-request@^5`: + +```ts +// `data` is properly typed, inferred from `allFilmsWithVariablesQueryDocument` type +const { data } = useQuery(['films'], async () => + request( + 'https://swapi-graphql.netlify.app/.netlify/functions/index', + allFilmsWithVariablesQueryDocument, + // variables are also properly type-checked. + { first: 10 } + ) +) +``` + +In order to infer the result type from a given GraphQL document, we added to `graphql-request@^5` the support for `TypedDocumentNode` documents, an enhanced version of `graphql`'s `DocumentNode` type. + +GraphQL Code Generator, via the `client` preset, generates GraphQL documents similar to the following: + +```ts +const query: TypedDocumentNode<{ greetings: string }, never | Record> = parse(/* GraphQL */ ` + query greetings { + greetings + } +`) +``` + +A `TypedDocumentNode` type carry 2 Generic arguments: the type of the GraphQL result `R` and the type of the GraphQL operation variables `V`. + +To implement your own React Query fetcher while preserving the GraphQL document type inference, it should implement a function signature that extract the result type and use it as a return type, as showcased below: + +```ts +const query: TypedDocumentNode<{ greetings: string }, never | Record> = parse(/* GraphQL */ ` + query greetings { + greetings + } +`) + +const myFetcher = (document: TypedDocumentNode): D => { + // ... parses the document and fetches the data ... +} + +const { data } = useQuery(['greetings'], () => myFetcher(query)) +``` + +The type-checking on variables arguments can be added to your custom React Query fetcher by following the same inference principles. + +Feel free [to reach out on GitHub discussions](https://github.com/dotansimha/graphql-code-generator/discussions) if you need any help in typing a custom React Query fetcher. + +
+ +--- + +
+ +## Appendix II: Compatibility + +GraphQL Code Generator `client` preset (`@graphql-codegen/client-preset`) is compatible with the following GraphQL clients and frameworks: + +- **React** + + - `@apollo/client` (since `3.2.0`, not when using React Components (``)) + - `@urql/core` (since `1.15.0`) + - `@urql/preact` (since `1.4.0`) + - `urql` (since `1.11.0`) + - `graphql-request` (since `5.0.0`) + - `react-query` (with `graphql-request@5.0.0`) + - `swr` (with `graphql-request@5.0.0`) + +- **Vue** + - `@vue/apollo-composable` (since `4.0.0-alpha.13`) + - `villus` (since `1.0.0-beta.8`) + - `@urql/vue` (since `1.11.0`) + +If your stack is not listed above, please refer to other guides ([Angular](/docs/guides/angular), [Svelte](/docs/guides/svelte)) or to [our plugins directory](/plugins). diff --git a/website/src/pages/docs/guides/react.mdx b/website/src/pages/docs/guides/react.mdx deleted file mode 100644 index 8d5c9f872e7..00000000000 --- a/website/src/pages/docs/guides/react.mdx +++ /dev/null @@ -1,633 +0,0 @@ -import { Tabs, Tab, Callout, PackageCmd } from '@theguild/components' - -# Guide: React and GraphQL - -GraphQL Code Generator provides typed code generation for React Query, URQL React, React Apollo Client, and other clients. - -The plugins and available options vary depending on the target client; for this reason, you find guides for each of them below: - -- [React Query](#react-query) -- [Apollo and URQL](#apollo-and-urql) - -All the following guides query the schema below: - -```graphql filename="schema.graphql" -type Author { - id: Int! - firstName: String! - lastName: String! - posts(findTitle: String): [Post] -} - -type Post { - id: Int! - title: String! - author: Author -} - -type Query { - posts: [Post] -} -``` - -## React Query - -Most React Query usage with GraphQL and TypeScript will look as follows: - -```tsx -import { useQuery } from '@tanstack/react-query' -import { request, gql } from 'graphql-request' - -interface PostQuery { - posts: { - id: string - title: string - author?: { - id: string - firstName: string - lastName: string - } - }[] -} - -const postsQueryDocument = gql` - query Posts { - posts { - id - title - author { - id - firstName - lastName - } - } - } -` - -const Posts = () => { - const { data } = useQuery('posts', async () => { - const { posts } = await request(endpoint, postsQueryDocument) - return posts - }) - - // … -} -``` - -Not typing or manually maintaining the data-types can lead to many issues: - -- **outdated typing** (regarding the current Schema) - -- **typos** - -- **partial typing** of data (not all Schema's fields has a corresponding type) - -For this reason, GraphQL Code Generator provides a `@graphql-codegen/typescript-react-query` plugin that generates a typed hook for each GraphQL operation. - -Just a few configuration steps are required to get those typed hooks generated: - -**1. Install the `@graphql-codegen/typescript-react-query` plugin** - - - -**2. Configure the plugin** - -Create or update your `codegen.yaml` file as follows: - -```yaml -schema: http://my-graphql-api.com/graphql -documents: './src/**/*.tsx' -generates: - ./graphql/generated.ts: - plugins: - - typescript - - typescript-operations - - typescript-react-query - config: - fetcher: fetch -``` - - -**`schema` and `documents` values** - -`schema` needs to be your target GraphQL API URL (`"/graphql"` included). - -`documents` is a glob expression to your `.graphql`, `.ts` or `.tsx` files. - - - -**3. Run the codegen and update your code** - -Assuming that, as recommended, your `package.json` has the following script: - -```json filename="package.json" -{ - "scripts": { - "generate": "graphql-codegen" - } -} -``` - -Running the following will generate the `graphql/generated.tsx` file. - - - -We can now update our code as follows: - -```tsx -import gql from 'graphql-tag' -import { useQuery } from '@tanstack/react-query' -import { usePosts } from '../graphql/generated' - -gql` - query Posts { - posts { - id - title - author { - id - firstName - lastName - } - } - } -` - -const Posts = () => { - const { data } = usePosts() - - // `data` is typed! - // … -} -``` - -For more advanced configuration (custom fetcher, infinite queries), please refer to the [plugin documentation](/plugins/typescript-react-query#using-graphql-request). - -For a different organization of the generated files, please refer to the ["Generated files colocation"](/docs/advanced/generated-files-colocation) page. - -## Apollo and URQL - -### Optimal configuration for Apollo and URQL - -While Apollo and URQL have their GraphQL Code Generator plugins that generate fully-typed hooks, they are also compatible with a plugin named `@graphql-codegen/typed-document-node`. - -`@graphql-codegen/typed-document-node` plugin provides the best Developer Experience for Apollo and URQL: - -- low bundle impact (compared to other plugins) -- better backward compatibility (easier to migrate to) -- more flexible - -Given the following code example: - - - -```tsx -import { gql, useQuery } from '@apollo/client' - -interface PostQuery { - posts: { - id: string - title: string - author?: { - id: string - firstName: string - lastName: string - } - }[] -} - -const postsQueryDocument = gql` - query Posts { - posts { - id - title - author { - id - firstName - lastName - } - } - } -` - -const Posts = () => { - const { data } = useQuery(postsQueryDocument) - - // … -} -``` - - - -```tsx -import { useQuery } from 'urql' - -interface PostQuery { - posts: { - id: string - title: string - author?: { - id: string - firstName: string - lastName: string - } - }[] -} - -const postsQueryDocument = ` - query Posts { - posts { - id - title - author { - id - firstName - lastName - } - } - } -` - -const Posts = () => { - const [result] = useQuery({ query: postsQueryDocument }) - - // … -} -``` - - - -installing and configuring the `@graphql-codegen/typed-document-node` plugin would allow the following refactoring: - - - -```tsx -import { useQuery } from '@apollo/client' -import { postsQueryDocument } from './graphql/generated' - -const Posts = () => { - const { data } = useQuery(postsQueryDocument) - - // `result` is fully typed! - // … -} -``` - - - -```tsx -import { useQuery } from 'urql' -import { postsQueryDocument } from './graphql/generated' - -const Posts = () => { - const [result] = useQuery({ query: postsQueryDocument }) - - // `result` is fully typed! - // … -} -``` - - - -Just a few configuration steps are required to get those typed document nodes generated: - -**1. Move all your GraphQL documents in dedicated `.graphql` files** - -To have `@graphql-codegen/typed-document-node` working and avoid code duplication, -we highly recommend moving all `gql` document declarations outside of `.tsx`/`.ts` files. -For this, create a colocated `.graphql` file for each GraphQL document, as follows: - -```graphql filename="postsQuery.graphql" -# 'Document' will be appended to the name of the query in the generated output -query postsQuery { - posts { - id - title - author { - id - firstName - lastName - } - } -} -``` - -**2. Install the `@graphql-codegen/typed-document-node` plugin** - - - -**3. Configure the plugin** - -Create or update your `codegen.yaml` file as follows: - -```yaml -schema: http://my-graphql-api.com/graphql -documents: './src/**/*.graphql' -generates: - ./src/generated.ts: - plugins: - - typescript - - typescript-operations - - typed-document-node -``` - - -**`schema` and `documents` values** - -`schema` needs to be your target GraphQL API URL (`"/graphql"` included). - -`documents` is a glob expression to your `.graphql` files. - - -**4. Run the codegen and update your code** - -Assuming that, as recommended, your `package.json` has the following script: - -```json filename="package.json" -{ - "scripts": { - "generate": "graphql-codegen" - } -} -``` - -Running the following generates the `graphql/generated.tsx` file. - - - -We can now update our code as follows: - - - -```tsx -import { useQuery } from '@apollo/client' -import { postsQueryDocument } from './graphql/generated' - -const Posts = () => { - const { data } = useQuery(postsQueryDocument) - - // `result` is fully typed! - // … -} -``` - - - -```tsx -import { useQuery } from 'urql' -import { postsQueryDocument } from './graphql/generated' - -const Posts = () => { - const [result] = useQuery({ query: postsQueryDocument }) - - // `result` is fully typed! - // … -} -``` - - - -For more advanced configuration, please refer to the [plugin documentation](/plugins/typed-document-node). - -If you are curious about `@graphql-codegen/typed-document-node` inner workings, feel free to read the following The Guild's CTO blog post: [TypedDocumentNode: the next generation of GraphQL and TypeScript](https://the-guild.dev/blog/typed-document-node). - -### Typed hooks for Apollo and URQL - -GraphQL Code Generator also proposes two plugins (one for Apollo one for URQL) that generate fully-typed hooks. - -Given the following code example: - - - -```tsx -import { gql, useQuery } from '@apollo/client' - -interface PostQuery { - posts: { - id: string - title: string - author?: { - id: string - firstName: string - lastName: string - } - }[] -} - -const postsQueryDocument = gql` - query Posts { - posts { - id - title - author { - id - firstName - lastName - } - } - } -` - -const Posts = () => { - const { data } = useQuery(postsQueryDocument) - - // … -} -``` - - - -```tsx -import { useQuery } from 'urql' - -interface PostQuery { - posts: { - id: string - title: string - author?: { - id: string - firstName: string - lastName: string - } - }[] -} - -const postsQueryDocument = /* GraphQL */` - query Posts { - posts { - id - title - author { - id - firstName - lastName - } - } - } -` - -const Posts = () => { - const [result] = useQuery({ query: postsQueryDocument }) - - // … -} -``` - - - -installing and configuring the `@graphql-codegen/typescript-react-apollo` or `@graphql-codegen/typescript-urql` plugin would allow the following refactoring: - - - -```tsx -import { usePostsQuery } from './graphql/generated' - -const Posts = () => { - const { data } = usePostsQuery() - - // `result` is fully typed! - // … -} -``` - - - -```tsx -import { usePostsQuery } from './graphql/generated' - -const Posts = () => { - const [result] = usePostsQuery() - - // `result` is fully typed! - // … -} -``` - - - -Some might prefer this approach to `@graphql-codegen/typed-document-node` since its results in a total abstraction of the query logic and fewer imports. - -However, keep in mind that hooks generation results in: - -- greater bundle size -- an increase of the number of specific hooks in the given application - -Just a few configuration steps are required to get those typed hooks generated: - -**1. Install the `@graphql-codegen/typescript-react-apollo` or `@graphql-codegen/typescript-urql` plugin** - -For React Apollo: - - - -For URQL React: - - - -**2. Configure the plugin** - -Create or update your `codegen.yaml` file as follows: - - - -```yaml -schema: http://my-graphql-api.com/graphql -documents: './src/**/*.tsx' -generates: - graphql/generated.ts: - plugins: - - typescript - - typescript-operations - - typescript-react-apollo - config: - withHooks: true -``` - - - -```yaml -schema: http://my-graphql-api.com/graphql -documents: './src/**/*.tsx' -generates: - graphql/generated.ts: - plugins: - - typescript - - typescript-operations - - typescript-urql - config: - withHooks: true -``` - - - - -**`schema` and `documents` values** - -`schema` needs to be your target GraphQL API URL (`"/graphql"` included). - -`documents` is a glob expression to your `.graphql`, `.ts` or `.tsx` files. - - - -**3. Run the codegen and update your code** - -Assuming that, as recommended, your `package.json` has the following script: - -```json filename="package.json" -{ - "scripts": { - "generate": "graphql-codegen" - } -} -``` - -Running the following generates the `graphql/generated.tsx` file. - - - -We can now update our code as follows: - - - -```tsx -import { usePostsQuery } from './graphql/generated' - -const Posts = () => { - const { data } = usePostsQuery() - - // `result` is fully typed! - // … -} -``` - - - -```tsx -import { usePostsQuery } from './graphql/generated' - -const Posts = () => { - const [result] = usePostsQuery() - - // `result` is fully typed! - // … -} -``` - - - -For more advanced configuration, please refer to the plugin documentation: - -- [`@graphql-codegen/typescript-react-apollo` documentation](/plugins/typescript-react-apollo) -- [`@graphql-codegen/typescript-urql` documentation](/plugins/typescript-urql) - -For a different organization of the generated files, please refer to the ["Generated files colocation"](/docs/advanced/generated-files-colocation) page. diff --git a/website/src/pages/docs/guides/vue.mdx b/website/src/pages/docs/guides/vue.mdx deleted file mode 100644 index 24fc87042b0..00000000000 --- a/website/src/pages/docs/guides/vue.mdx +++ /dev/null @@ -1,312 +0,0 @@ -import { Callout, PackageCmd } from '@theguild/components' - -# Guide: Vue.js - -GraphQL Code Generator provides typed code generation for URQL Vue.js and Apollo Vue.js. - -The plugins and available options vary depending on the target client; for this reason, you will find guides for each of them below: - -- [URQL Vue.js](#vuejs-urql) -- [Apollo Vue.js](#vuejs-apollo) - -All the following guides query the schema below: - -```graphql filename="schema.graphql" -type Author { - id: Int! - firstName: String! - lastName: String! - posts(findTitle: String): [Post] -} - -type Post { - id: Int! - title: String! - author: Author -} - -type Query { - post(id: ID!): Post -} -``` - -## Vue.js URQL - -Most Vue.js URQL usage with TypeScript will look as follows: - -```vue - - - -``` - -Not typing or manually maintaining the data-types can lead to many issues: - -- **outdated typing** (regarding the current Schema) - -- **typos** - -- **partial typing** of data (not all Schema's fields has a corresponding type) - -For this reason, GraphQL Code Generator is providing a `@graphql-codegen/typescript-vue-urql` plugin that generates fully-typed composition functions with: - -- typed URQL options -- typed variables -- typed result - -**1. Install the `@graphql-codegen/typescript-vue-urql` plugin** - - - -**2. Configure the plugin** - -Create or update your `codegen.yaml` file as follows: - -```yaml -schema: http://my-graphql-api.com/graphql -documents: './src/**/*.tsx' -generates: - ./graphql/generated.ts: - plugins: - - typescript - - typescript-operations - - typescript-vue-urql -``` - - -**`schema` and `documents` values** - -`schema` needs to be your target GraphQL API URL (`"/graphql"` included). - -`documents` is a glob expression to your `.graphql`, `.ts` or `.tsx` files. - - - -**3. Run the codegen and update your code** - -Assuming that, as recommended, your `package.json` has the following script: - -```json filename="package.json" -{ - "scripts": { - "generate": "graphql-codegen" - } -} -``` - -Running the following generates the `graphql/generated.tsx` file. - - - -We can now update our code as follows: - -```vue - - - -``` - -For more advanced configuration, please refer to the [plugin documentation](/plugins/typescript-vue-urql). - -For a different organization of the generated files, please refer to the ["Generated files colocation"](/docs/advanced/generated-files-colocation) page. - -## Vue.js Apollo - -Most Vue.js Apollo usage with TypeScript will look as follows: - -```vue - - - -``` - -Not typing or manually maintaining the data-types can lead to many issues: - -- **outdated typing** (regarding the current Schema) - -- **typos** - -- **partial typing** of data (not all Schema's fields has a corresponding type) - -For this reason, GraphQL Code Generator is providing a `@graphql-codegen/typescript-vue-apollo-smart-ops` plugin that generates fully-typed composition functions with: - -- typed URQL options -- typed variables -- typed result - -**1. Install the `@graphql-codegen/typescript-vue-apollo-smart-ops` plugin** - - - -**2. Configure the plugin** - -Create or update your `codegen.yaml` file as follows: - -```yaml -schema: http://my-graphql-api.com/graphql -documents: './src/**/*.tsx' -generates: - ./graphql/generated.ts: - plugins: - - typescript - - typescript-operations - - typescript-vue-apollo-smart-ops -``` - - -**`schema` and `documents` values** - -`schema` needs to be your target GraphQL API URL (`"/graphql"` included). - -`documents` is a glob expression to your `.graphql`, `.ts` or `.tsx` files. - - - -**3. Run the codegen and update your code** - -Assuming that, as recommended, your `package.json` has the following script: - -```json filename="package.json" -{ - "scripts": { - "generate": "graphql-codegen" - } -} -``` - -Running the following generates the `graphql/generated.tsx` file. - - - -We can now update our code as follows: - -```vue - - - -``` - -For more advanced configuration, please refer to the [plugin documentation](/plugins/typescript-vue-apollo-smart-ops). - -For a different organization of the generated files, please refer to the ["Generated files colocation"](/docs/advanced/generated-files-colocation) page. diff --git a/yarn.lock b/yarn.lock index 9366ff2c7e4..071ed7706bd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -343,7 +343,7 @@ json5 "^2.2.1" semver "^6.3.0" -"@babel/generator@^7.14.0", "@babel/generator@^7.19.0", "@babel/generator@^7.7.2": +"@babel/generator@^7.14.0", "@babel/generator@^7.18.13", "@babel/generator@^7.19.0", "@babel/generator@^7.7.2": version "7.19.0" resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.19.0.tgz#785596c06425e59334df2ccee63ab166b738419a" integrity sha512-S1ahxf1gZ2dpoiFgA+ohK9DIpz50bJ0CWs7Zlzb54Z4sG8qmdIrGrVqmy1sAtTVRb+9CU6U8VqT9L0Zj7hxHVg== @@ -1281,7 +1281,7 @@ debug "^4.1.0" globals "^11.1.0" -"@babel/types@^7.0.0", "@babel/types@^7.16.8", "@babel/types@^7.18.10", "@babel/types@^7.18.6", "@babel/types@^7.18.9", "@babel/types@^7.19.0", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4": +"@babel/types@^7.0.0", "@babel/types@^7.16.8", "@babel/types@^7.18.10", "@babel/types@^7.18.13", "@babel/types@^7.18.6", "@babel/types@^7.18.9", "@babel/types@^7.19.0", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4": version "7.19.0" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.19.0.tgz#75f21d73d73dc0351f3368d28db73465f4814600" integrity sha512-YuGopBq3ke25BVSiS6fgF49Ul9gH1x70Bcr6bqRLjWCkcX8Hre1/5+z+IiWOIerRMSSEfGZVB9z9kyq7wVs9YA== @@ -1695,6 +1695,34 @@ "@graphql-tools/load" "6.2.4" tslib "2.0.2" +"@graphql-codegen/client-preset@1.0.1-alpha-20220823170145-c93d8aee3": + version "1.0.1-alpha-20220823170145-c93d8aee3" + resolved "https://registry.yarnpkg.com/@graphql-codegen/client-preset/-/client-preset-1.0.1-alpha-20220823170145-c93d8aee3.tgz#a0536be350843b1d9b8668598ac98ba1ca90b95a" + integrity sha512-Ou/Mmut+KSGA/rYOCs8rterYu2b1Ar4EssRHQNiTdNceXCp4K4eh/8nFit6+ute9TDeYUsTb6gaEKkbztisNbA== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + "@babel/template" "^7.15.4" + "@graphql-codegen/add" "^3.2.1" + "@graphql-codegen/gql-tag-operations" "1.5.0-alpha-20220823170145-c93d8aee3" + "@graphql-codegen/plugin-helpers" "^2.6.2" + "@graphql-codegen/typed-document-node" "^2.3.3" + "@graphql-codegen/typescript" "^2.7.3" + "@graphql-codegen/typescript-operations" "^2.5.3" + "@graphql-codegen/visitor-plugin-common" "^2.12.1" + "@graphql-tools/utils" "^8.8.0" + tslib "~2.4.0" + +"@graphql-codegen/gql-tag-operations@1.5.0-alpha-20220823170145-c93d8aee3": + version "1.5.0-alpha-20220823170145-c93d8aee3" + resolved "https://registry.yarnpkg.com/@graphql-codegen/gql-tag-operations/-/gql-tag-operations-1.5.0-alpha-20220823170145-c93d8aee3.tgz#5d4226a0dea28af5f2298d8ee0b55a1344a3e76b" + integrity sha512-cEC4l5Py5NLk7Lidhpj4lBwoS5G7UyoNeamIQ7DBdkLOK1zN/2HJCleDVp0HIEJM+nJv12vFmZBJ0NUrDTu7aQ== + dependencies: + "@graphql-codegen/plugin-helpers" "^2.6.2" + "@graphql-codegen/visitor-plugin-common" "2.12.1" + "@graphql-tools/utils" "^8.8.0" + auto-bind "~4.0.0" + tslib "~2.4.0" + "@graphql-codegen/visitor-plugin-common@2.12.0": version "2.12.0" resolved "https://registry.yarnpkg.com/@graphql-codegen/visitor-plugin-common/-/visitor-plugin-common-2.12.0.tgz#49b055c5c2c5c0890f2226ce9e84bb73dfd83801" @@ -1859,6 +1887,14 @@ "@graphql-tools/utils" "8.9.0" tslib "^2.4.0" +"@graphql-tools/merge@8.3.4": + version "8.3.4" + resolved "https://registry.yarnpkg.com/@graphql-tools/merge/-/merge-8.3.4.tgz#749f710d3a930512e6ca36e3bb053c12e22ef332" + integrity sha512-2z1UpHvvI52nQZIYArU+rPq1lOENWetsdb+6vu8yLGyCRP4CpKMBvtmiHkbrlPBO8dItpZ08szXEoaStfJHBxQ== + dependencies: + "@graphql-tools/utils" "8.10.1" + tslib "^2.4.0" + "@graphql-tools/merge@8.3.5", "@graphql-tools/merge@^8.2.6": version "8.3.5" resolved "https://registry.yarnpkg.com/@graphql-tools/merge/-/merge-8.3.5.tgz#6360a05759c05f00a68b18a83295a2663f4b9324" @@ -1985,6 +2021,13 @@ dependencies: tslib "~2.3.0" +"@graphql-tools/utils@8.10.1": + version "8.10.1" + resolved "https://registry.yarnpkg.com/@graphql-tools/utils/-/utils-8.10.1.tgz#3224fe0611c9bb51e6a5c16472ae17afbd6c5465" + integrity sha512-UYi/afPvxZ8mz0LjplMxOSmGDPenVS/Q0zJ/6LOyF9yZdJYIDe+J+Qr/I9+rCYQmgBW4BJeRUUc7VoUzZPfZDA== + dependencies: + tslib "^2.4.0" + "@graphql-tools/utils@8.11.0": version "8.11.0" resolved "https://registry.yarnpkg.com/@graphql-tools/utils/-/utils-8.11.0.tgz#764ebf9b371b5f5cb66228267c68bea996cc8c03" @@ -3478,16 +3521,16 @@ resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.31.tgz#31b7ca6407128a3d2bbc27fe2d21b345397f6197" integrity sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA== -"@types/node@*", "@types/node@16.11.58", "@types/node@^16.9.2": - version "16.11.58" - resolved "https://registry.yarnpkg.com/@types/node/-/node-16.11.58.tgz#0a3698dee3492617a8d5fe7998d18d7520b63026" - integrity sha512-uMVxJ111wpHzkx/vshZFb6Qni3BOMnlWLq7q9jrwej7Yw/KvjsEbpxCCxw+hLKxexFMc8YmpG8J9tnEe/rKsIg== - -"@types/node@18.6.2": +"@types/node@*", "@types/node@18.6.2": version "18.6.2" resolved "https://registry.yarnpkg.com/@types/node/-/node-18.6.2.tgz#ffc5f0f099d27887c8d9067b54e55090fcd54126" integrity sha512-KcfkBq9H4PI6Vpu5B/KoPeuVDAbmi+2mDBqGPGUgoL7yXQtcWGu2vJWmmRkneWK3Rh0nIAX192Aa87AqKHYChQ== +"@types/node@16.11.58", "@types/node@^16.9.2": + version "16.11.58" + resolved "https://registry.yarnpkg.com/@types/node/-/node-16.11.58.tgz#0a3698dee3492617a8d5fe7998d18d7520b63026" + integrity sha512-uMVxJ111wpHzkx/vshZFb6Qni3BOMnlWLq7q9jrwej7Yw/KvjsEbpxCCxw+hLKxexFMc8YmpG8J9tnEe/rKsIg== + "@types/node@^10.1.0": version "10.17.60" resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.60.tgz#35f3d6213daed95da7f0f73e75bcc6980e90597b" @@ -5178,7 +5221,7 @@ cosmiconfig-toml-loader@1.0.0: dependencies: "@iarna/toml" "^2.2.5" -cosmiconfig-typescript-loader@^4.0.0: +cosmiconfig-typescript-loader@4.0.0, cosmiconfig-typescript-loader@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-4.0.0.tgz#4a6d856c1281135197346a6f64dfa73a9cd9fefa" integrity sha512-cVpucSc2Tf+VPwCCR7SZzmQTQkPbkk4O01yXsYqXBIbjE1bhwqSyAgYQkRK1un4i0OPziTleqFhdkmOc4RQ/9g==