angular/packages/compiler-cli/test/ngtsc/signal_forms_spec.ts
Leon Senft 3606902b33
refactor(forms): relax [formField] input type from FieldTree to Field
`FieldTree` was an unnecessarily specific type for the `[formField]`
input. It forced the directive to care about what _kind_ of `FieldTree`
was bound–specifically whether it was Reactive Forms compatible or not.
This made it difficult to author forms system-agnostic components with
passthrough `[formField]` inputs.
2026-02-11 11:45:20 -08:00

630 lines
19 KiB
TypeScript

/*!
* @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 ts from 'typescript';
import {runInEachFileSystem} from '../../src/ngtsc/file_system/testing';
import {loadStandardTestFiles} from '../../src/ngtsc/testing';
import {NgtscTestEnvironment} from './env';
const testFiles = loadStandardTestFiles({forms: true});
runInEachFileSystem(() => {
describe('signal forms', () => {
let env!: NgtscTestEnvironment;
beforeEach(() => {
env = NgtscTestEnvironment.setup(testFiles);
env.tsconfig({
// This is the default in Angular apps so we enable it to ensure consistent behavior.
strict: true,
strictTemplates: true,
});
});
function extractMessage(diag: ts.Diagnostic) {
return typeof diag.messageText === 'string' ? diag.messageText : diag.messageText.messageText;
}
it('should check that the field is present', () => {
env.write(
'test.ts',
`
import {Component} from '@angular/core';
import {FormField} from '@angular/forms/signals';
@Component({
template: '<input [formField]="null"/>',
imports: [FormField]
})
export class Comp {}
`,
);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(1);
expect(extractMessage(diags[0])).toBe(
`Type 'null' is not assignable to type 'Field<any, string | number>'.`,
);
});
it('should check that the field type is correct', () => {
env.write(
'test.ts',
`
import {Component} from '@angular/core';
import {FormField} from '@angular/forms/signals';
@Component({
template: '<input formField="staticString"/>',
imports: [FormField]
})
export class Comp {}
`,
);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(1);
expect(extractMessage(diags[0])).toBe(
`Type 'string' is not assignable to type 'Field<any, string | number>'.`,
);
});
it('should treat FormField directives not coming from the forms module as regular directives', () => {
env.write(
'field.ts',
`
import {Directive, input} from '@angular/core';
@Directive({selector: '[formField]'})
export class FormField {
readonly formField = input.required<string>();
}
`,
);
env.write(
'test.ts',
`
import {Component} from '@angular/core';
import {FormField} from './field';
@Component({
template: '<input [formField]="null"/>',
imports: [FormField]
})
export class Comp {}
`,
);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(1);
expect(extractMessage(diags[0])).toBe(`Type 'null' is not assignable to type 'string'.`);
});
it('should infer an input without a `type` as a string field', () => {
env.write(
'test.ts',
`
import {Component, signal} from '@angular/core';
import {FormField, form} from '@angular/forms/signals';
@Component({
template: '<input [formField]="f"/>',
imports: [FormField]
})
export class Comp {
f = form(signal(0));
}
`,
);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(1);
expect(extractMessage(diags[0])).toBe(`Type 'number' is not assignable to type 'string'.`);
});
it('should infer the type of the field from the input `type`', () => {
env.write(
'test.ts',
`
import {Component, signal} from '@angular/core';
import {FormField, form} from '@angular/forms/signals';
@Component({
template: '<input type="date" [formField]="f"/>',
imports: [FormField]
})
export class Comp {
f = form(signal({}));
}
`,
);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(1);
expect(extractMessage(diags[0])).toBe(
`Type '{}' is not assignable to type 'string | number | Date | null'.`,
);
});
it('should infer the type of a custom value control', () => {
env.write(
'test.ts',
`
import {Component, signal, model} from '@angular/core';
import {FormField, form, FormValueControl} from '@angular/forms/signals';
interface User {
firstName: string;
lastName: string;
}
@Component({selector: 'user-control', template: ''})
export class UserControl implements FormValueControl<User> {
readonly value = model<User>({firstName: 'Frodo', lastName: 'Baggins'});
}
@Component({
template: '<user-control [formField]="f"/>',
imports: [FormField, UserControl]
})
export class Comp {
f = form(signal({name: 'Bilbo'}));
}
`,
);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(1);
expect(extractMessage(diags[0])).toBe(
`Type '{ name: string; }' is missing the following properties from type 'User': firstName, lastName`,
);
});
it('should infer the type of a custom checkbox control', () => {
env.write(
'test.ts',
`
import {Component, signal, model} from '@angular/core';
import {FormField, form, FormCheckboxControl} from '@angular/forms/signals';
@Component({selector: 'my-checkbox', template: ''})
export class MyCheckbox implements FormCheckboxControl {
readonly checked = model(false);
}
@Component({
template: '<my-checkbox [formField]="f"/>',
imports: [FormField, MyCheckbox]
})
export class Comp {
f = form(signal(''));
}
`,
);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(1);
expect(extractMessage(diags[0])).toBe(`Type 'string' is not assignable to type 'boolean'.`);
});
it('should infer the type of a generic custom control', () => {
env.write(
'test.ts',
`
import {Component, signal, model} from '@angular/core';
import {FormField, form, FormValueControl} from '@angular/forms/signals';
@Component({selector: 'custom-control', template: ''})
export class CustomControl<T> implements FormValueControl<T> {
readonly value = model.required<T>();
}
@Component({
template: \`
<custom-control [formField]="f" #comp/>
{{expectsString(comp.value())}}
\`,
imports: [FormField, CustomControl]
})
export class Comp {
f = form(signal(0));
expectsString(value: string) {}
}
`,
);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(1);
expect(extractMessage(diags[0])).toBe(
`Argument of type 'number' is not assignable to parameter of type 'string'.`,
);
});
it('should not report a custom control that conforms to `FormValueControl`', () => {
env.write(
'test.ts',
`
import {Component, signal, model} from '@angular/core';
import {FormField, form, FormValueControl} from '@angular/forms/signals';
@Component({ selector: 'string-control', template: '' })
class StringControl implements FormValueControl<string> {
value = model('');
}
@Component({
selector: 'app-root',
imports: [FormField, StringControl],
template: '<string-control [formField]="field" />',
})
class App {
field = form(signal(''));
}
`,
);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(0);
});
it('should report unsupported property bindings on a field', () => {
env.write(
'test.ts',
`
import {Component, signal} from '@angular/core';
import {FormField, form} from '@angular/forms/signals';
@Component({
template: '<input type="number" [formField]="f" [max]="10"/>',
imports: [FormField]
})
export class Comp {
f = form(signal(0));
}
`,
);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(1);
expect(extractMessage(diags[0])).toBe(
`Binding to '[max]' is not allowed on nodes using the '[formField]' directive`,
);
});
it('should report unsupported attribute bindings on a field', () => {
env.write(
'test.ts',
`
import {Component, signal} from '@angular/core';
import {FormField, form} from '@angular/forms/signals';
@Component({
template: '<input [formField]="f" [attr.maxlength]="maxLength"/>',
imports: [FormField]
})
export class Comp {
f = form(signal(''));
maxLength = 10;
}
`,
);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(1);
expect(extractMessage(diags[0])).toBe(
`Binding to '[attr.maxlength]' is not allowed on nodes using the '[formField]' directive`,
);
});
it('should report unsupported property bindings on a field with a custom control', () => {
env.write(
'test.ts',
`
import {Component, signal, model, input} from '@angular/core';
import {FormField, form, FormValueControl} from '@angular/forms/signals';
@Component({selector: 'custom-control', template: ''})
export class CustomControl implements FormValueControl<number> {
readonly value = model<number>(0);
readonly max = input<number | undefined>(1);
}
@Component({
template: '<custom-control [formField]="f" [max]="2"/>',
imports: [FormField, CustomControl]
})
export class Comp {
f = form(signal(0));
}
`,
);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(1);
expect(extractMessage(diags[0])).toBe(
`Binding to '[max]' is not allowed on nodes using the '[formField]' directive`,
);
});
it('should allow binding to `value` on radio controls', () => {
env.write(
'test.ts',
`
import {Component, signal} from '@angular/core';
import {FormField, form} from '@angular/forms/signals';
@Component({
template: \`
<form>
<input type="radio" value="a" [formField]="f">
<input type="radio" value="b" [formField]="f">
<input type="radio" value="c" [formField]="f">
</form>
\`,
imports: [FormField]
})
export class Comp {
f = form(signal('a'), {name: 'test'});
}
`,
);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(0);
});
it('should check that the radio button value is a string', () => {
env.write(
'test.ts',
`
import {Component, signal} from '@angular/core';
import {FormField, form} from '@angular/forms/signals';
@Component({
template: \`
<form>
<input type="radio" [value]="num" [formField]="f">
</form>
\`,
imports: [FormField]
})
export class Comp {
f = form(signal('a'), {name: 'test'});
num = 1;
}
`,
);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(1);
expect(diags[0].messageText).toBe(`Type 'number' is not assignable to type 'string'.`);
});
it('should report unsupported static attributes of a field', () => {
env.write(
'test.ts',
`
import {Component, signal} from '@angular/core';
import {FormField, form} from '@angular/forms/signals';
@Component({
template: '<input value="Hello" [formField]="f"/>',
imports: [FormField]
})
export class Comp {
f = form(signal(''));
}
`,
);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(1);
expect(extractMessage(diags[0])).toBe(
`Setting the 'value' attribute is not allowed on nodes using the '[formField]' directive`,
);
});
it('should check that a custom value control conforms to FormValueControl', () => {
env.write(
'test.ts',
`
import {Component, signal, model, input} from '@angular/core';
import {FormField, form} from '@angular/forms/signals';
@Component({selector: 'user-control', template: ''})
export class UserControl {
readonly value = model<number>(0);
required = input<number>(0);
}
@Component({
template: '<user-control [formField]="f"/>',
imports: [FormField, UserControl]
})
export class Comp {
f = form(signal(1));
}
`,
);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(1);
expect(extractMessage(diags[0])).toBe(`Type 'boolean' is not assignable to type 'number'.`);
});
it('should check that a custom checkbox control conforms to FormCheckboxControl', () => {
env.write(
'test.ts',
`
import {Component, signal, model, input} from '@angular/core';
import {FormField, form} from '@angular/forms/signals';
@Component({selector: 'user-control', template: ''})
export class UserControl {
readonly checked = model<boolean>(false);
required = input<number>(0);
}
@Component({
template: '<user-control [formField]="f"/>',
imports: [FormField, UserControl]
})
export class Comp {
f = form(signal(true));
}
`,
);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(1);
expect(extractMessage(diags[0])).toBe(`Type 'boolean' is not assignable to type 'number'.`);
});
it('should not report `value` as a missing required input when the `FormField` directive is present', () => {
env.write(
'test.ts',
`
import {Component, signal, model} from '@angular/core';
import {FormField, form, FormValueControl} from '@angular/forms/signals';
@Component({selector: 'custom-control', template: ''})
export class CustomControl implements FormValueControl<string> {
readonly value = model.required<string>();
}
@Component({
template: '<custom-control [formField]="f"/>',
imports: [FormField, CustomControl]
})
export class Comp {
f = form(signal(''));
}
`,
);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(0);
});
it('should not report `checked` as a missing required input when the `FormField` directive is present', () => {
env.write(
'test.ts',
`
import {Component, signal, model} from '@angular/core';
import {FormField, form, FormCheckboxControl} from '@angular/forms/signals';
@Component({selector: 'custom-control', template: ''})
export class CustomControl implements FormCheckboxControl {
readonly checked = model.required<boolean>();
}
@Component({
template: '<custom-control [formField]="f"/>',
imports: [FormField, CustomControl]
})
export class Comp {
f = form(signal(false));
}
`,
);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(0);
});
it('should not check field on native control that has a ControlValueAccessor directive', () => {
env.write(
'test.ts',
`
import {Component, Directive, signal} from '@angular/core';
import {ControlValueAccessor} from '@angular/forms';
import {FormField, form} from '@angular/forms/signals';
@Directive({selector: '[customCva]'})
export class CustomCva implements ControlValueAccessor {
writeValue() {}
registerOnChange() {}
registerOnTouched() {}
}
@Component({
template: '<input customCva [formField]="f"/>',
imports: [FormField, CustomCva]
})
export class Comp {
f = form(signal(0));
}
`,
);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(0);
});
it('should not check field on native control that has a directive inheriting from a ControlValueAccessor', () => {
env.write(
'test.ts',
`
import {Component, Directive, signal} from '@angular/core';
import {ControlValueAccessor} from '@angular/forms';
import {FormField, form} from '@angular/forms/signals';
@Directive()
export class Grandparent implements ControlValueAccessor {
writeValue() {}
registerOnChange() {}
registerOnTouched() {}
}
@Directive()
export class Parent extends Grandparent {}
@Directive({selector: '[customCva]'})
export class CustomCva extends Parent {}
@Component({
template: '<input customCva [formField]="f"/>',
imports: [FormField, CustomCva]
})
export class Comp {
f = form(signal(0));
}
`,
);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(0);
});
it('should infer an input with a dynamic `type` as being any of the other types', () => {
env.write(
'test.ts',
`
import {Component, signal} from '@angular/core';
import {FormField, form} from '@angular/forms/signals';
@Component({
template: '<input [type]="type" [formField]="f"/>',
imports: [FormField]
})
export class Comp {
type = '';
f = form(signal({test: true}));
}
`,
);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(1);
expect(extractMessage(diags[0])).toBe(
`Type '{ test: boolean; }' is not assignable to type 'string | number | boolean | Date | null'.`,
);
});
});
});