diff --git a/.changeset/extract-fragment-refactor.md b/.changeset/extract-fragment-refactor.md new file mode 100644 index 00000000..e01e81f8 --- /dev/null +++ b/.changeset/extract-fragment-refactor.md @@ -0,0 +1,5 @@ +--- +'@0no-co/graphqlsp': minor +--- + +Add an "Extract to fragment" refactor for `graphql()` call-expression documents. When you select one or more complete fields inside a document's selection set, the editor now offers an "Extract to fragment" action under the "GraphQL" refactor group, which creates a new `graphql()` fragment document (named after the fields' parent type) above the current statement, replaces the selected fields with a fragment spread, and adds the new fragment variable to the call's fragment array. diff --git a/README.md b/README.md index 574a7e28..04c30ea3 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ auto-complete. - Diagnostics for adding fields that don't exist, are deprecated, missmatched argument types, ... - Auto-complete inside your editor for fields - Will warn you when you are importing from a file that is exporting fragments that you're not using +- An "Extract to fragment" refactor that moves selected fields into a new co-located fragment (in `graphql()` call-expression mode) > Note that this plugin does not do syntax highlighting, for that you still need something like > [the VSCode/... plugin](https://marketplace.visualstudio.com/items?itemName=GraphQL.vscode-graphql-syntax) diff --git a/packages/graphqlsp/README.md b/packages/graphqlsp/README.md index bf73052b..ff36fd16 100644 --- a/packages/graphqlsp/README.md +++ b/packages/graphqlsp/README.md @@ -10,6 +10,7 @@ auto-complete. - Diagnostics for adding fields that don't exist, are deprecated, missmatched argument types, ... - Auto-complete inside your editor for fields - Will warn you when you are importing from a file that is exporting fragments that you're not using +- An "Extract to fragment" refactor that moves selected fields into a new co-located fragment (in `graphql()` call-expression mode) > Note that this plugin does not do syntax highlighting, for that you still need something like > [the VSCode/... plugin](https://marketplace.visualstudio.com/items?itemName=GraphQL.vscode-graphql-syntax) diff --git a/packages/graphqlsp/src/extractFragment.ts b/packages/graphqlsp/src/extractFragment.ts new file mode 100644 index 00000000..7265df9b --- /dev/null +++ b/packages/graphqlsp/src/extractFragment.ts @@ -0,0 +1,381 @@ +import { + BREAK, + DocumentNode, + Kind, + TypeInfo, + parse, + visit, + visitWithTypeInfo, +} from 'graphql'; + +import { ts } from './ts'; +import { findAllCallExpressions, getSource } from './ast'; +import { SchemaRef, getSchemaForName } from './graphql/getSchema'; + +/** Tokens GraphQL ignores between selections: whitespace and commas. */ +const IGNORED_CHARACTER = /[\s,]/; +const WHITESPACE_ONLY = /^[ \t]*$/; + +interface ExtractableSelection { + source: ts.SourceFile; + /** The `graphql()`/`gql()` call whose document contains the selection. */ + call: ts.CallExpression; + /** The document string literal, i.e. the call's first argument. */ + document: ts.StringLiteralLike; + /** The document's text as written in the file, without its quotes. */ + documentText: string; + /** Position of `documentText` in the file. */ + documentOffset: number; + /** Selection range relative to `documentText`, trimmed of surrounding + * ignored tokens and validated to cover complete sibling fields. */ + start: number; + end: number; + /** The GraphQL type the selected fields are selected on. */ + parentTypeName: string; + /** All GraphQL documents in the file, for fragment-name uniqueness. */ + documentNodes: readonly ts.StringLiteralLike[]; +} + +/** Unwraps `as T`/`as const` around the call's fragment-array argument. */ +const getFragmentArrayLiteral = ( + call: ts.CallExpression +): ts.Expression | undefined => { + let node: ts.Expression | undefined = call.arguments[1]; + while (node && ts.isAsExpression(node)) node = node.expression; + return node; +}; + +const findExtractableSelection = ( + filename: string, + positionOrRange: number | ts.TextRange, + schema: SchemaRef, + info: ts.server.PluginCreateInfo +): ExtractableSelection | undefined => { + // The refactor is only supported for call-expression documents; in + // tagged-template mode fragments aren't composed via a fragment array + const isCallExpression = info.config.templateIsCallExpression ?? true; + if (!isCallExpression) return undefined; + + // A collapsed cursor position can never cover a complete field selection + if ( + typeof positionOrRange === 'number' || + positionOrRange.pos >= positionOrRange.end + ) { + return undefined; + } + + const source = getSource(info, filename); + if (!source) return undefined; + + const { nodes } = findAllCallExpressions(source, info, { + searchExternal: false, + collectFragments: false, + }); + + for (const { node, schema: schemaName } of nodes) { + const documentOffset = node.getStart() + 1; + const documentText = node.getText().slice(1, -1); + if ( + positionOrRange.pos < documentOffset || + positionOrRange.end > documentOffset + documentText.length + ) { + continue; + } + + if (!ts.isCallExpression(node.parent) || node.parent.arguments[0] !== node) + return undefined; + const call = node.parent; + + // The new fragment is referenced through the call's fragment array, so + // a second argument that isn't an array literal disqualifies the call + const fragmentArray = getFragmentArrayLiteral(call); + if (fragmentArray && !ts.isArrayLiteralExpression(fragmentArray)) + return undefined; + + let start = positionOrRange.pos - documentOffset; + let end = positionOrRange.end - documentOffset; + while (start < end && IGNORED_CHARACTER.test(documentText[start]!)) start++; + while (end > start && IGNORED_CHARACTER.test(documentText[end - 1]!)) end--; + if (start >= end) return undefined; + + const schemaToUse = getSchemaForName(schema, schemaName); + if (!schemaToUse) return undefined; + + let document: DocumentNode; + try { + document = parse(documentText); + } catch (_error) { + return undefined; + } + + // The trimmed selection is only extractable when it exactly tiles one + // or more complete sibling field selections: it must begin at a field's + // start and consecutive siblings must line up with the selection's end + let parentTypeName: string | undefined; + const typeInfo = new TypeInfo(schemaToUse); + visit( + document, + visitWithTypeInfo(typeInfo, { + SelectionSet(selectionSet) { + const index = selectionSet.selections.findIndex( + selection => selection.loc && selection.loc.start === start + ); + if (index === -1) return undefined; + for (let i = index; i < selectionSet.selections.length; i++) { + const selection = selectionSet.selections[i]!; + if ( + selection.kind !== Kind.FIELD || + !selection.loc || + selection.loc.end > end + ) { + break; + } else if (selection.loc.end === end) { + // `TypeInfo` has already entered this selection set, so the + // parent type here is the type the selections apply to + parentTypeName = typeInfo.getParentType()?.name; + break; + } + } + // A selection can only start at one field in the whole document, + // so whether the run matched or not, the search is over + return BREAK; + }, + }) + ); + if (!parentTypeName) return undefined; + + return { + source, + call, + document: node, + documentText, + documentOffset, + start, + end, + parentTypeName, + documentNodes: nodes.map(x => x.node), + }; + } + + return undefined; +}; + +/** Checks whether the selected range covers complete field selections that + * can be extracted into a co-located fragment. */ +export const canExtractFragment = ( + filename: string, + positionOrRange: number | ts.TextRange, + schema: SchemaRef, + info: ts.server.PluginCreateInfo +): boolean => + !!findExtractableSelection(filename, positionOrRange, schema, info); + +const toVariableName = (fragmentName: string): string => + fragmentName.charAt(0).toLowerCase() + fragmentName.slice(1); + +/** Picks `Fields` (or a numbered variant) such that neither the + * fragment name is taken by a document in the file, nor the derived variable + * name occurs in the file's text. */ +const pickFragmentName = ( + source: ts.SourceFile, + documentNodes: readonly ts.StringLiteralLike[], + parentTypeName: string +): string => { + const takenFragmentNames = new Set(); + for (const node of documentNodes) { + try { + const parsed = parse(node.getText().slice(1, -1), { noLocation: true }); + for (const definition of parsed.definitions) { + if (definition.kind === Kind.FRAGMENT_DEFINITION) + takenFragmentNames.add(definition.name.value); + } + } catch (_error) {} + } + + const fileText = source.getText(); + const isTaken = (name: string): boolean => + takenFragmentNames.has(name) || + new RegExp(`\\b${toVariableName(name)}\\b`).test(fileText); + + let fragmentName = `${parentTypeName}Fields`; + for (let counter = 2; isTaken(fragmentName); counter++) + fragmentName = `${parentTypeName}Fields${counter}`; + return fragmentName; +}; + +const getLineStart = (text: string, position: number): number => + text.lastIndexOf('\n', position - 1) + 1; + +/** Guesses the file's indentation unit from the document's own lines, + * relative to the statement's base indentation. */ +const detectIndentUnit = (documentText: string, baseIndent: string): string => { + for (const line of documentText.split('\n')) { + if (!line.trim()) continue; + const match = /^[ \t]+/.exec(line); + if (!match) continue; + let indent = match[0]; + if (indent.startsWith(baseIndent)) indent = indent.slice(baseIndent.length); + if (indent) return indent; + } + return ' '; +}; + +/** The position the new fragment statement is inserted at: the start of the + * line of the statement containing the call, above any leading comments + * that are on their own lines. */ +const getInsertionPoint = ( + source: ts.SourceFile, + call: ts.CallExpression +): { position: number; indent: string } => { + const fileText = source.getText(); + + let statement: ts.Node = call; + while ( + statement.parent && + !ts.isSourceFile(statement.parent) && + !ts.isBlock(statement.parent) + ) { + statement = statement.parent; + } + + let reference = statement.getStart(); + const comments = ts.getLeadingCommentRanges( + fileText, + statement.getFullStart() + ); + if (comments && comments.length) { + const commentLineStart = getLineStart(fileText, comments[0]!.pos); + if ( + WHITESPACE_ONLY.test(fileText.slice(commentLineStart, comments[0]!.pos)) + ) + reference = comments[0]!.pos; + } + + const lineStart = getLineStart(fileText, reference); + const linePrefix = fileText.slice(lineStart, reference); + return WHITESPACE_ONLY.test(linePrefix) + ? { position: lineStart, indent: linePrefix } + : { position: reference, indent: '' }; +}; + +const printFragmentStatement = ( + found: ExtractableSelection, + fragmentName: string, + baseIndent: string +): string => { + const { source, call, document, documentText, documentOffset, start, end } = + found; + const fileText = source.getText(); + const variableName = toVariableName(fragmentName); + const callee = call.expression.getText(); + const quote = document.getText().charAt(0); + const selectedText = documentText.slice(start, end); + + if (quote !== '`') { + // Regular string literals can't span lines, so the fragment document + // is emitted on a single line, mirroring the current call's style + const fields = selectedText.replace(/[\s,]+/g, ' ').trim(); + return ( + `${baseIndent}const ${variableName} = ${callee}(${quote}` + + `fragment ${fragmentName} on ${found.parentTypeName} { ${fields} }` + + `${quote});\n\n` + ); + } + + const indentUnit = detectIndentUnit(documentText, baseIndent); + const fieldIndent = baseIndent + indentUnit + indentUnit; + + // The indentation the selected fields currently have: the whitespace + // preceding the first selected field on its own line + const selectionLineStart = getLineStart(fileText, documentOffset + start); + const selectionLinePrefix = fileText.slice( + selectionLineStart, + documentOffset + start + ); + const selectionIndent = WHITESPACE_ONLY.test(selectionLinePrefix) + ? selectionLinePrefix + : ''; + + const fields = selectedText.split('\n').map((line, index) => { + if (index === 0) return fieldIndent + line; + line = line.replace(/\r$/, ''); + if (!line.trim()) return ''; + return selectionIndent && line.startsWith(selectionIndent) + ? fieldIndent + line.slice(selectionIndent.length) + : fieldIndent + line.trimStart(); + }); + + return ( + [ + `${baseIndent}const ${variableName} = ${callee}(${quote}`, + `${baseIndent}${indentUnit}fragment ${fragmentName} on ${found.parentTypeName} {`, + ...fields, + `${baseIndent}${indentUnit}}`, + `${baseIndent}${quote});`, + ].join('\n') + '\n\n' + ); +}; + +/** Computes the edits for the 'Extract to fragment' refactor: a new + * fragment statement above the current one, a fragment spread replacing the + * selected fields, and the new variable added to the call's fragment array. */ +export const getExtractFragmentEdits = ( + filename: string, + positionOrRange: number | ts.TextRange, + schema: SchemaRef, + info: ts.server.PluginCreateInfo +): ts.RefactorEditInfo | undefined => { + const found = findExtractableSelection( + filename, + positionOrRange, + schema, + info + ); + if (!found) return undefined; + + const fragmentName = pickFragmentName( + found.source, + found.documentNodes, + found.parentTypeName + ); + const variableName = toVariableName(fragmentName); + + const insertion = getInsertionPoint(found.source, found.call); + const textChanges: ts.TextChange[] = [ + { + span: { start: insertion.position, length: 0 }, + newText: printFragmentStatement(found, fragmentName, insertion.indent), + }, + { + span: { + start: found.documentOffset + found.start, + length: found.end - found.start, + }, + newText: `...${fragmentName}`, + }, + ]; + + const fragmentArray = getFragmentArrayLiteral(found.call); + if (fragmentArray && ts.isArrayLiteralExpression(fragmentArray)) { + if (fragmentArray.elements.length) { + const lastElement = + fragmentArray.elements[fragmentArray.elements.length - 1]!; + textChanges.push({ + span: { start: lastElement.getEnd(), length: 0 }, + newText: `, ${variableName}`, + }); + } else { + textChanges.push({ + span: { start: fragmentArray.getEnd() - 1, length: 0 }, + newText: variableName, + }); + } + } else { + textChanges.push({ + span: { start: found.document.getEnd(), length: 0 }, + newText: `, [${variableName}]`, + }); + } + + return { edits: [{ fileName: filename, textChanges }] }; +}; diff --git a/packages/graphqlsp/src/index.ts b/packages/graphqlsp/src/index.ts index bfe33065..1a613951 100644 --- a/packages/graphqlsp/src/index.ts +++ b/packages/graphqlsp/src/index.ts @@ -11,6 +11,7 @@ import { import { ALL_DIAGNOSTICS, getGraphQLDiagnostics } from './diagnostics'; import { templates } from './ast/templates'; import { getPersistedCodeFixAtPosition } from './persisted'; +import { canExtractFragment, getExtractFragmentEdits } from './extractFragment'; function createBasicDecorator(info: ts.server.PluginCreateInfo) { const proxy: ts.LanguageService = Object.create(null); @@ -127,7 +128,38 @@ function create(info: ts.server.PluginCreateInfo) { preferences, interactive ) => { - const original = info.languageService.getEditsForRefactor( + if (refactorName === 'GraphQL') { + if (actionName === 'Insert document-id') { + const codefix = guard('getEditsForRefactor', undefined, () => + getPersistedCodeFixAtPosition( + filename, + typeof positionOrRange === 'number' + ? positionOrRange + : positionOrRange.pos, + info + ) + ); + if (codefix) { + return { + edits: [ + { + fileName: filename, + textChanges: [ + { newText: codefix.replacement, span: codefix.span }, + ], + }, + ], + }; + } + } else if (actionName === 'Extract to fragment') { + const refactor = guard('getEditsForRefactor', undefined, () => + getExtractFragmentEdits(filename, positionOrRange, schema, info) + ); + if (refactor) return refactor; + } + } + + return info.languageService.getEditsForRefactor( filename, formatOptions, positionOrRange, @@ -136,25 +168,6 @@ function create(info: ts.server.PluginCreateInfo) { preferences, interactive ); - - const codefix = guard('getEditsForRefactor', undefined, () => - getPersistedCodeFixAtPosition( - filename, - typeof positionOrRange === 'number' - ? positionOrRange - : positionOrRange.pos, - info - ) - ); - if (!codefix) return original; - return { - edits: [ - { - fileName: filename, - textChanges: [{ newText: codefix.replacement, span: codefix.span }], - }, - ], - }; }; proxy.getApplicableRefactors = ( @@ -174,6 +187,8 @@ function create(info: ts.server.PluginCreateInfo) { includeInteractive ); + const actions: ts.RefactorActionInfo[] = []; + const codefix = guard('getApplicableRefactors', undefined, () => getPersistedCodeFixAtPosition( filename, @@ -183,19 +198,31 @@ function create(info: ts.server.PluginCreateInfo) { info ) ); - if (codefix) { + actions.push({ + name: 'Insert document-id', + description: + 'Generate a document-id for your persisted-operation, by default a SHA256 hash.', + }); + } + + const extractFragment = guard('getApplicableRefactors', false, () => + canExtractFragment(filename, positionOrRange, schema, info) + ); + if (extractFragment) { + actions.push({ + name: 'Extract to fragment', + description: + 'Extract the selected fields into a new co-located fragment.', + }); + } + + if (actions.length) { return [ { name: 'GraphQL', description: 'Operations specific to gql.tada!', - actions: [ - { - name: 'Insert document-id', - description: - 'Generate a document-id for your persisted-operation, by default a SHA256 hash.', - }, - ], + actions, inlineable: true, }, ...original, diff --git a/test/e2e/extract-fragment.test.ts b/test/e2e/extract-fragment.test.ts new file mode 100644 index 00000000..8252b36a --- /dev/null +++ b/test/e2e/extract-fragment.test.ts @@ -0,0 +1,404 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { TSServer } from './server'; +import path from 'node:path'; +import fs from 'node:fs'; +import url from 'node:url'; +import ts from 'typescript/lib/tsserverlibrary'; + +const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); + +const projectPath = path.resolve(__dirname, 'fixture-project-tada'); + +const fixtureContent = fs.readFileSync( + path.join(projectPath, 'fixtures/extract-fragment.ts'), + 'utf-8' +); + +const indexOf = (search: string): number => { + const index = fixtureContent.indexOf(search); + if (index === -1) throw new Error(`Could not find ${search} in fixture`); + return index; +}; + +const toLocation = (index: number): ts.server.protocol.Location => { + const before = fixtureContent.slice(0, index); + return { + line: before.split('\n').length, + offset: index - before.lastIndexOf('\n'), + }; +}; + +const toRangeArgs = ( + start: number, + end: number +): Omit => { + const startLocation = toLocation(start); + const endLocation = toLocation(end); + return { + startLine: startLocation.line, + startOffset: startLocation.offset, + endLine: endLocation.line, + endOffset: endLocation.offset, + }; +}; + +const applyEdits = ( + content: string, + textChanges: readonly ts.server.protocol.CodeEdit[] +): string => { + const lineStarts = [0]; + for (let i = 0; i < content.length; i++) + if (content[i] === '\n') lineStarts.push(i + 1); + const toPosition = (location: ts.server.protocol.Location): number => + lineStarts[location.line - 1]! + location.offset - 1; + + const sorted = [...textChanges].sort( + (a, b) => toPosition(b.start) - toPosition(a.start) + ); + for (const change of sorted) { + content = + content.slice(0, toPosition(change.start)) + + change.newText + + content.slice(toPosition(change.end)); + } + return content; +}; + +// A selection spanning the complete `id`, `name` and `fleeRate` fields of +// the `Pokemons` query, including leading and trailing whitespace +const fieldRunAnchor = 'id\n name\n fleeRate'; +const validSelection = { + start: indexOf(fieldRunAnchor) - 6, + end: indexOf(fieldRunAnchor) + fieldRunAnchor.length + 1, +}; + +describe('Extract to fragment refactor', () => { + const outfile = path.join(projectPath, 'extract-fragment.ts'); + + let server: TSServer; + + const getApplicableRefactors = async (range: { + start: number; + end: number; + }): Promise => { + server.send({ + type: 'request', + command: 'getApplicableRefactors', + arguments: { file: outfile, ...toRangeArgs(range.start, range.end) }, + }); + await server.waitForResponse( + response => + response.type === 'response' && + response.command === 'getApplicableRefactors' + ); + const response = [...server.responses] + .reverse() + .find( + resp => + resp.type === 'response' && resp.command === 'getApplicableRefactors' + ) as ts.server.protocol.GetApplicableRefactorsResponse; + return response.body || []; + }; + + const getExtractFragmentAction = ( + refactors: ts.server.protocol.ApplicableRefactorInfo[] + ) => + refactors + .find(refactor => refactor.name === 'GraphQL') + ?.actions.find(action => action.name === 'Extract to fragment'); + + const getEditsForExtractFragment = async (range: { + start: number; + end: number; + }): Promise => { + server.send({ + type: 'request', + command: 'getEditsForRefactor', + arguments: { + file: outfile, + ...toRangeArgs(range.start, range.end), + refactor: 'GraphQL', + action: 'Extract to fragment', + }, + }); + await server.waitForResponse( + response => + response.type === 'response' && + response.command === 'getEditsForRefactor' + ); + const response = [...server.responses] + .reverse() + .find( + resp => + resp.type === 'response' && resp.command === 'getEditsForRefactor' + ) as ts.server.protocol.GetEditsForRefactorResponse; + return response.body!; + }; + + beforeAll(async () => { + server = new TSServer(projectPath, { debugLog: false }); + + server.sendCommand('open', { + file: outfile, + fileContent: '// empty', + scriptKindName: 'TS', + } satisfies ts.server.protocol.OpenRequestArgs); + + server.sendCommand('updateOpen', { + openFiles: [{ file: outfile, fileContent: fixtureContent }], + } satisfies ts.server.protocol.UpdateOpenRequestArgs); + + server.sendCommand('saveto', { + file: outfile, + tmpfile: outfile, + } satisfies ts.server.protocol.SavetoRequestArgs); + + // The schema is loaded asynchronously and the refactor is only offered + // once it is; poll a known-good selection until it becomes available + for (let attempt = 0; attempt < 60; attempt++) { + if ( + getExtractFragmentAction(await getApplicableRefactors(validSelection)) + ) + return; + await new Promise(resolve => setTimeout(resolve, 500)); + } + throw new Error( + 'The "Extract to fragment" refactor never became available.' + ); + }, 45000); + + afterAll(() => { + try { + fs.unlinkSync(outfile); + } catch {} + server.close(); + }); + + it('offers the refactor for a selection covering complete sibling fields', async () => { + const action = getExtractFragmentAction( + await getApplicableRefactors(validSelection) + ); + expect(action).toBeDefined(); + expect(action).toMatchInlineSnapshot(` + { + "description": "Extract the selected fields into a new co-located fragment.", + "name": "Extract to fragment", + } + `); + }, 30000); + + it('does not offer the refactor for a selection ending inside a field', async () => { + // From the start of `id` to the middle of `name` + const refactors = await getApplicableRefactors({ + start: indexOf(fieldRunAnchor), + end: indexOf(fieldRunAnchor) + 'id\n na'.length, + }); + expect(getExtractFragmentAction(refactors)).toBeUndefined(); + }, 30000); + + it('does not offer the refactor for a selection crossing a selection set boundary', async () => { + // From `fast {` to the end of `damage`, i.e. not covering `fast`'s + // closing brace + const refactors = await getApplicableRefactors({ + start: indexOf('fast {'), + end: indexOf('damage') + 'damage'.length, + }); + expect(getExtractFragmentAction(refactors)).toBeUndefined(); + }, 30000); + + it('does not offer the refactor for a collapsed selection', async () => { + const refactors = await getApplicableRefactors({ + start: indexOf(fieldRunAnchor), + end: indexOf(fieldRunAnchor), + }); + expect(getExtractFragmentAction(refactors)).toBeUndefined(); + }, 30000); + + it('extracts selected fields into a new fragment appended to the fragment array', async () => { + const editInfo = await getEditsForExtractFragment(validSelection); + expect(editInfo.edits).toHaveLength(1); + expect(editInfo.edits[0]!.fileName).toBe(outfile); + + const result = applyEdits(fixtureContent, editInfo.edits[0]!.textChanges); + expect(result).toMatchInlineSnapshot(` + "import { graphql } from './graphql'; + + // prettier-ignore + const existingFields = graphql(\` + fragment PokemonFields on Pokemon { + maxCP + maxHP + } + \`); + + const pokemonFields2 = graphql(\` + fragment PokemonFields2 on Pokemon { + id + name + fleeRate + } + \`); + + // prettier-ignore + const pokemonsQuery = graphql(\` + query Pokemons($limit: Int!) { + pokemons(limit: $limit) { + ...PokemonFields2 + attacks { + fast { + name + damage + } + } + __typename + ...PokemonFields + } + } + \`, [existingFields, pokemonFields2]); + + // prettier-ignore + const pokemonQuery = graphql(\` + query Pokemon($id: ID!) { + pokemon(id: $id) { + id + name + evolutions { + id + name + } + } + } + \`); + + console.log(existingFields, pokemonsQuery, pokemonQuery); + " + `); + }, 30000); + + it('extracts a field with a nested selection set at the end of a run', async () => { + // From `attacks` to the end of `__typename`, covering the complete + // nested selection set of `attacks` + const editInfo = await getEditsForExtractFragment({ + start: indexOf('attacks {'), + end: indexOf('__typename') + '__typename'.length, + }); + + const result = applyEdits(fixtureContent, editInfo.edits[0]!.textChanges); + expect(result).toMatchInlineSnapshot(` + "import { graphql } from './graphql'; + + // prettier-ignore + const existingFields = graphql(\` + fragment PokemonFields on Pokemon { + maxCP + maxHP + } + \`); + + const pokemonFields2 = graphql(\` + fragment PokemonFields2 on Pokemon { + attacks { + fast { + name + damage + } + } + __typename + } + \`); + + // prettier-ignore + const pokemonsQuery = graphql(\` + query Pokemons($limit: Int!) { + pokemons(limit: $limit) { + id + name + fleeRate + ...PokemonFields2 + ...PokemonFields + } + } + \`, [existingFields, pokemonFields2]); + + // prettier-ignore + const pokemonQuery = graphql(\` + query Pokemon($id: ID!) { + pokemon(id: $id) { + id + name + evolutions { + id + name + } + } + } + \`); + + console.log(existingFields, pokemonsQuery, pokemonQuery); + " + `); + }, 30000); + + it('adds a fragment array to a call that has none', async () => { + const anchor = 'evolutions {\n id\n name\n }'; + const editInfo = await getEditsForExtractFragment({ + start: indexOf(anchor), + end: indexOf(anchor) + anchor.length, + }); + + const result = applyEdits(fixtureContent, editInfo.edits[0]!.textChanges); + expect(result).toMatchInlineSnapshot(` + "import { graphql } from './graphql'; + + // prettier-ignore + const existingFields = graphql(\` + fragment PokemonFields on Pokemon { + maxCP + maxHP + } + \`); + + // prettier-ignore + const pokemonsQuery = graphql(\` + query Pokemons($limit: Int!) { + pokemons(limit: $limit) { + id + name + fleeRate + attacks { + fast { + name + damage + } + } + __typename + ...PokemonFields + } + } + \`, [existingFields]); + + const pokemonFields2 = graphql(\` + fragment PokemonFields2 on Pokemon { + evolutions { + id + name + } + } + \`); + + // prettier-ignore + const pokemonQuery = graphql(\` + query Pokemon($id: ID!) { + pokemon(id: $id) { + id + name + ...PokemonFields2 + } + } + \`, [pokemonFields2]); + + console.log(existingFields, pokemonsQuery, pokemonQuery); + " + `); + }, 30000); +}); diff --git a/test/e2e/fixture-project-tada/fixtures/extract-fragment.ts b/test/e2e/fixture-project-tada/fixtures/extract-fragment.ts new file mode 100644 index 00000000..c0bf8e52 --- /dev/null +++ b/test/e2e/fixture-project-tada/fixtures/extract-fragment.ts @@ -0,0 +1,44 @@ +import { graphql } from './graphql'; + +// prettier-ignore +const existingFields = graphql(` + fragment PokemonFields on Pokemon { + maxCP + maxHP + } +`); + +// prettier-ignore +const pokemonsQuery = graphql(` + query Pokemons($limit: Int!) { + pokemons(limit: $limit) { + id + name + fleeRate + attacks { + fast { + name + damage + } + } + __typename + ...PokemonFields + } + } +`, [existingFields]); + +// prettier-ignore +const pokemonQuery = graphql(` + query Pokemon($id: ID!) { + pokemon(id: $id) { + id + name + evolutions { + id + name + } + } + } +`); + +console.log(existingFields, pokemonsQuery, pokemonQuery);