From cc5096637a5a67ea5819a6db317e8bdc067df5aa Mon Sep 17 00:00:00 2001 From: Jovi De Croock Date: Sat, 4 Jul 2026 20:40:11 +0200 Subject: [PATCH] feat: add 'Extract to fragment' refactor When a selection inside a graphql() document's selection set covers one or more complete sibling field selections, offer an 'Extract to fragment' action under the existing 'GraphQL' refactor group. Applying it 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 appends the new fragment variable to the call's fragment array (creating the array when the call has none). The refactor dispatch in getEditsForRefactor now switches on the refactor/action name, and getApplicableRefactors merges all applicable GraphQL actions into a single refactor group. Co-Authored-By: Claude Fable 5 --- .changeset/extract-fragment-refactor.md | 5 + README.md | 1 + packages/graphqlsp/README.md | 1 + packages/graphqlsp/src/extractFragment.ts | 381 +++++++++++++++++ packages/graphqlsp/src/index.ts | 83 ++-- test/e2e/extract-fragment.test.ts | 404 ++++++++++++++++++ .../fixtures/extract-fragment.ts | 44 ++ 7 files changed, 891 insertions(+), 28 deletions(-) create mode 100644 .changeset/extract-fragment-refactor.md create mode 100644 packages/graphqlsp/src/extractFragment.ts create mode 100644 test/e2e/extract-fragment.test.ts create mode 100644 test/e2e/fixture-project-tada/fixtures/extract-fragment.ts 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);