mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
`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.
630 lines
19 KiB
TypeScript
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'.`,
|
|
);
|
|
});
|
|
});
|
|
});
|