mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
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:
parent
15ca29fed4
commit
6342befff8
4 changed files with 384 additions and 6 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
}
|
||||
|
|
@ -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,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue