feat(language-service): support migrating full classes to signal queries (#58263)

Adds a similar code action as for inputs, where users can migrate full
classes to signal queries.

PR Close #58263
This commit is contained in:
Paul Gschwendtner 2024-10-18 13:28:15 +00:00
parent 15ca29fed4
commit 6342befff8
4 changed files with 384 additions and 6 deletions

View file

@ -106,7 +106,7 @@ export async function applySignalQueriesRefactoring(
} else if (incompatibilityMessages.size > 0) {
const queryPlural = incompatibilityMessages.size === 1 ? 'query' : `queries`;
message = `${incompatibilityMessages.size} ${queryPlural} could not be migrated.\n`;
message += `For more details, click on the skipped inputs and try to migrate individually.\n`;
message += `For more details, click on the skipped queries and try to migrate individually.\n`;
}
// Only suggest the "force ignoring" option if there are actually

View file

@ -0,0 +1,117 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {CompilerOptions} from '@angular/compiler-cli';
import {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core';
import {MigrationConfig} from '@angular/core/schematics/migrations/signal-migration/src';
import {ApplyRefactoringProgressFn, ApplyRefactoringResult} from '@angular/language-service/api';
import ts from 'typescript';
import {isTypeScriptFile} from '../../utils';
import {findTightestNode, getParentClassDeclaration} from '../../utils/ts_utils';
import type {ActiveRefactoring} from '../refactoring';
import {isDecoratorQueryClassField, isDirectiveOrComponentWithQueries} from './decorators';
import {applySignalQueriesRefactoring} from './apply_query_refactoring';
/**
* Base language service refactoring action that can convert decorator
* queries of a full class to signal queries.
*
* The user can click on an class with decorator queries and ask for all the queries
* to be migrated. All references, imports and the declaration are updated automatically.
*/
abstract class BaseConvertFullClassToSignalQueriesRefactoring implements ActiveRefactoring {
abstract config: MigrationConfig;
constructor(private project: ts.server.Project) {}
static isApplicable(
compiler: NgCompiler,
fileName: string,
positionOrRange: number | ts.TextRange,
): boolean {
if (!isTypeScriptFile(fileName)) {
return false;
}
const sf = compiler.getCurrentProgram().getSourceFile(fileName);
if (sf === undefined) {
return false;
}
const start = typeof positionOrRange === 'number' ? positionOrRange : positionOrRange.pos;
const node = findTightestNode(sf, start);
if (node === undefined) {
return false;
}
const classDecl = getParentClassDeclaration(node);
if (classDecl === undefined) {
return false;
}
const {reflector} = compiler['ensureAnalyzed']();
if (!isDirectiveOrComponentWithQueries(classDecl, reflector)) {
return false;
}
const parentClassElement = ts.findAncestor(node, (n) => ts.isClassElement(n) || ts.isBlock(n));
if (parentClassElement === undefined) {
return true;
}
// If we are inside a body of e.g. an accessor, this action should not show up.
if (ts.isBlock(parentClassElement)) {
return false;
}
return isDecoratorQueryClassField(parentClassElement, reflector);
}
async computeEditsForFix(
compiler: NgCompiler,
compilerOptions: CompilerOptions,
fileName: string,
positionOrRange: number | ts.TextRange,
reportProgress: ApplyRefactoringProgressFn,
): Promise<ApplyRefactoringResult> {
const sf = compiler.getCurrentProgram().getSourceFile(fileName);
if (sf === undefined) {
return {edits: []};
}
const start = typeof positionOrRange === 'number' ? positionOrRange : positionOrRange.pos;
const node = findTightestNode(sf, start);
if (node === undefined) {
return {edits: []};
}
const containingClass = getParentClassDeclaration(node);
if (containingClass === null) {
return {edits: [], errorMessage: 'Could not find a class for the refactoring.'};
}
return await applySignalQueriesRefactoring(
compiler,
compilerOptions,
this.config,
this.project,
reportProgress,
(queryID) => queryID.node.parent === containingClass,
/** allowPartialMigration */ true,
);
}
}
export class ConvertFullClassToSignalQueriesRefactoring extends BaseConvertFullClassToSignalQueriesRefactoring {
static id = 'convert-full-class-to-signal-queries-safe-mode';
static description = 'Full class: Convert all decorator queries to signal queries (safe)';
override config: MigrationConfig = {};
}
export class ConvertFullClassToSignalQueriesBestEffortRefactoring extends BaseConvertFullClassToSignalQueriesRefactoring {
static id = 'convert-full-class-to-signal-queries-best-effort-mode';
static description =
'Full class: Convert all decorator queries to signal queries (forcibly, ignoring errors)';
override config: MigrationConfig = {bestEffortMode: true};
}

View file

@ -22,6 +22,10 @@ import {
ConvertFieldToSignalQueryBestEffortRefactoring,
ConvertFieldToSignalQueryRefactoring,
} from './convert_to_signal_queries/individual_query_refactoring';
import {
ConvertFullClassToSignalQueriesBestEffortRefactoring,
ConvertFullClassToSignalQueriesRefactoring,
} from './convert_to_signal_queries/full_class_query_refactoring';
/**
* Interface exposing static metadata for a {@link Refactoring},
@ -80,4 +84,6 @@ export const allRefactorings: Refactoring[] = [
// Queries migration
ConvertFieldToSignalQueryRefactoring,
ConvertFieldToSignalQueryBestEffortRefactoring,
ConvertFullClassToSignalQueriesRefactoring,
ConvertFullClassToSignalQueriesBestEffortRefactoring,
];

View file

@ -35,9 +35,11 @@ describe('Signal queries refactoring action', () => {
appFile.moveCursorToText('re¦f!: ElementRef');
const refactorings = project.getRefactoringsAtPosition('app.ts', appFile.cursor);
expect(refactorings.length).toBe(2);
expect(refactorings.length).toBe(4);
expect(refactorings[0].name).toBe('convert-field-to-signal-query-safe-mode');
expect(refactorings[1].name).toBe('convert-field-to-signal-query-best-effort-mode');
expect(refactorings[2].name).toBe('convert-full-class-to-signal-queries-safe-mode');
expect(refactorings[3].name).toBe('convert-full-class-to-signal-queries-best-effort-mode');
});
it('should not support refactoring a non-Angular property', () => {
@ -97,9 +99,11 @@ describe('Signal queries refactoring action', () => {
appFile.moveCursorToText('re¦f!: ElementRef');
const refactorings = project.getRefactoringsAtPosition('app.ts', appFile.cursor);
expect(refactorings.length).toBe(2);
expect(refactorings.length).toBe(4);
expect(refactorings[0].name).toBe('convert-field-to-signal-query-safe-mode');
expect(refactorings[1].name).toBe('convert-field-to-signal-query-best-effort-mode');
expect(refactorings[2].name).toBe('convert-full-class-to-signal-queries-safe-mode');
expect(refactorings[3].name).toBe('convert-full-class-to-signal-queries-best-effort-mode');
const edits = await project.applyRefactoring(
'app.ts',
@ -145,9 +149,11 @@ describe('Signal queries refactoring action', () => {
appFile.moveCursorToText('bl¦a: ElementRef');
const refactorings = project.getRefactoringsAtPosition('app.ts', appFile.cursor);
expect(refactorings.length).toBe(2);
expect(refactorings.length).toBe(4);
expect(refactorings[0].name).toBe('convert-field-to-signal-query-safe-mode');
expect(refactorings[1].name).toBe('convert-field-to-signal-query-best-effort-mode');
expect(refactorings[2].name).toBe('convert-full-class-to-signal-queries-safe-mode');
expect(refactorings[3].name).toBe('convert-full-class-to-signal-queries-best-effort-mode');
const edits = await project.applyRefactoring(
'app.ts',
@ -179,9 +185,11 @@ describe('Signal queries refactoring action', () => {
appFile.moveCursorToText('set bl¦a(');
const refactorings = project.getRefactoringsAtPosition('app.ts', appFile.cursor);
expect(refactorings.length).toBe(2);
expect(refactorings.length).toBe(4);
expect(refactorings[0].name).toBe('convert-field-to-signal-query-safe-mode');
expect(refactorings[1].name).toBe('convert-field-to-signal-query-best-effort-mode');
expect(refactorings[2].name).toBe('convert-full-class-to-signal-queries-safe-mode');
expect(refactorings[3].name).toBe('convert-full-class-to-signal-queries-best-effort-mode');
const edits = await project.applyRefactoring(
'app.ts',
@ -243,9 +251,11 @@ describe('Signal queries refactoring action', () => {
appFile.moveCursorToText('re¦f?: ElementRef');
const refactorings = project.getRefactoringsAtPosition('app.ts', appFile.cursor);
expect(refactorings.length).toBe(2);
expect(refactorings.length).toBe(4);
expect(refactorings[0].name).toBe('convert-field-to-signal-query-safe-mode');
expect(refactorings[1].name).toBe('convert-field-to-signal-query-best-effort-mode');
expect(refactorings[2].name).toBe('convert-full-class-to-signal-queries-safe-mode');
expect(refactorings[3].name).toBe('convert-full-class-to-signal-queries-best-effort-mode');
const edits = await project.applyRefactoring(
'app.ts',
@ -269,4 +279,249 @@ describe('Signal queries refactoring action', () => {
},
]);
});
describe('full class', () => {
it('should support refactoring multiple query properties', () => {
const files = {
'app.ts': `
import {Directive, ViewChild, ContentChild, ElementRef} from '@angular/core';
@Directive({})
export class AppComponent {
@ViewChild('refA') refA!: ElementRef;
@ContentChild('refB') refB?: ElementRef;
}
`,
};
const project = createModuleAndProjectWithDeclarations(env, 'test', files);
const appFile = project.openFile('app.ts');
appFile.moveCursorToText('App¦Component');
const refactorings = project.getRefactoringsAtPosition('app.ts', appFile.cursor);
expect(refactorings.length).toBe(2);
expect(refactorings[0].name).toBe('convert-full-class-to-signal-queries-safe-mode');
expect(refactorings[1].name).toBe('convert-full-class-to-signal-queries-best-effort-mode');
});
it('should not suggest options when inside an accessor query body', async () => {
const files = {
'app.ts': `
import {Directive, ViewChild, ElementRef} from '@angular/core';
@Directive({})
export class AppComponent {
@ViewChild('ref')
set bla(value: ElementRef) {
// hello
};
}
`,
};
const project = createModuleAndProjectWithDeclarations(env, 'test', files);
const appFile = project.openFile('app.ts');
appFile.moveCursorToText('hell¦o');
const refactorings = project.getRefactoringsAtPosition('app.ts', appFile.cursor);
expect(refactorings.length).toBe(0);
});
it('should generate edits for migrating multiple query properties', async () => {
const files = {
'app.ts': `
import {Directive, ViewChild, ContentChild, ElementRef} from '@angular/core';
@Directive({})
export class AppComponent {
@ViewChild('refA') refA!: ElementRef;
@ContentChild('refB') refB?: ElementRef;
}
`,
};
const project = createModuleAndProjectWithDeclarations(env, 'test', files);
const appFile = project.openFile('app.ts');
appFile.moveCursorToText('App¦Component');
const refactorings = project.getRefactoringsAtPosition('app.ts', appFile.cursor);
expect(refactorings.length).toBe(2);
expect(refactorings[0].name).toBe('convert-full-class-to-signal-queries-safe-mode');
expect(refactorings[1].name).toBe('convert-full-class-to-signal-queries-best-effort-mode');
const result = await project.applyRefactoring(
'app.ts',
appFile.cursor,
refactorings[0].name,
() => {},
);
expect(result).toBeDefined();
expect(result?.errorMessage).toBe(undefined);
expect(result?.warningMessage).toBe(undefined);
expect(result?.edits).toEqual([
{
fileName: '/test/app.ts',
textChanges: [
// Query declarations.
{
newText: `readonly refA = viewChild.required<ElementRef>('refA');`,
span: {start: 165, length: `@ViewChild('refA') refA!: ElementRef;`.length},
},
{
newText: `readonly refB = contentChild<ElementRef>('refB');`,
span: {start: 215, length: `@ContentChild('refB') refB?: ElementRef;`.length},
},
// Import.
{
newText: '{Directive, ElementRef, viewChild, contentChild}',
span: {start: 18, length: 48},
},
],
},
]);
});
it('should generate edits for partially migrating multiple query properties', async () => {
const files = {
'app.ts': `
import {Directive, ViewChild, ContentChild, ElementRef} from '@angular/core';
@Directive({})
export class AppComponent {
@ViewChild('refA') refA!: ElementRef;
@ContentChild('refB') refB?: ElementRef;
click() {
this.refB = undefined;
}
}
`,
};
const project = createModuleAndProjectWithDeclarations(env, 'test', files);
const appFile = project.openFile('app.ts');
appFile.moveCursorToText('App¦Component');
const refactorings = project.getRefactoringsAtPosition('app.ts', appFile.cursor);
expect(refactorings.length).toBe(2);
expect(refactorings[0].name).toBe('convert-full-class-to-signal-queries-safe-mode');
expect(refactorings[1].name).toBe('convert-full-class-to-signal-queries-best-effort-mode');
const result = await project.applyRefactoring(
'app.ts',
appFile.cursor,
refactorings[0].name,
() => {},
);
expect(result).toBeDefined();
expect(result?.warningMessage).toContain('1 query could not be migrated.');
expect(result?.warningMessage).toContain(
'click on the skipped queries and try to migrate individually.',
);
expect(result?.warningMessage).toContain('action to forcibly convert.');
expect(result?.errorMessage).toBe(undefined);
expect(result?.edits).toEqual([
{
fileName: '/test/app.ts',
textChanges: [
// Query declarations.
{
newText: `readonly refA = viewChild.required<ElementRef>('refA');`,
span: {start: 165, length: `@ViewChild('refA') refA!: ElementRef;`.length},
},
// Import
{
newText: '{Directive, ContentChild, ElementRef, viewChild}',
span: {start: 18, length: 48},
},
],
},
]);
});
it('should error when no queries could be migrated', async () => {
const files = {
'app.ts': `
import {Directive, ViewChild, ViewChildren, QueryList, ElementRef} from '@angular/core';
@Directive({})
export class AppComponent {
@ViewChild('ref1') bla!: ElementRef;
@ViewChildren('refs') bla2!: QueryList<ElementRef>;
click() {
this.bla = undefined;
this.bla2.changes.subscribe();
}
}
`,
};
const project = createModuleAndProjectWithDeclarations(env, 'test', files);
const appFile = project.openFile('app.ts');
appFile.moveCursorToText('App¦Component');
const refactorings = project.getRefactoringsAtPosition('app.ts', appFile.cursor);
expect(refactorings.length).toBe(2);
expect(refactorings[0].name).toBe('convert-full-class-to-signal-queries-safe-mode');
expect(refactorings[1].name).toBe('convert-full-class-to-signal-queries-best-effort-mode');
const result = await project.applyRefactoring(
'app.ts',
appFile.cursor,
refactorings[0].name,
() => {},
);
expect(result).toBeDefined();
expect(result?.errorMessage).toContain('2 queries could not be migrated.');
expect(result?.errorMessage).toContain(
'click on the skipped queries and try to migrate individually.',
);
expect(result?.errorMessage).toContain('action to forcibly convert.');
expect(result?.warningMessage).toBe(undefined);
expect(result?.edits).toEqual([]);
});
it('should not suggest force mode when all queries are incompatible and non-ignorable', async () => {
const files = {
'app.ts': `
import {Directive, ViewChild} from '@angular/core';
@Directive({})
export class AppComponent {
@ViewChild('ref1') set bla(v: string) {};
@ViewChild('ref2') set bla2(v: string) {};
}
`,
};
const project = createModuleAndProjectWithDeclarations(env, 'test', files);
const appFile = project.openFile('app.ts');
appFile.moveCursorToText('App¦Component');
const refactorings = project.getRefactoringsAtPosition('app.ts', appFile.cursor);
expect(refactorings.length).toBe(2);
expect(refactorings[0].name).toBe('convert-full-class-to-signal-queries-safe-mode');
expect(refactorings[1].name).toBe('convert-full-class-to-signal-queries-best-effort-mode');
const result = await project.applyRefactoring(
'app.ts',
appFile.cursor,
refactorings[0].name,
() => {},
);
expect(result).toBeDefined();
expect(result?.errorMessage).toContain('2 queries could not be migrated.');
expect(result?.errorMessage).toContain(
'click on the skipped queries and try to migrate individually.',
);
expect(result?.errorMessage).not.toContain('action to forcibly convert.');
expect(result?.warningMessage).toBe(undefined);
expect(result?.edits).toEqual([]);
});
});
});