refactor(forms): add unsupported method errors and docs

- Add disable, enable methods that throw with helpful messages
- Add validator methods (set/add/remove/clear) that throw
- Add setErrors, markAsPending methods that throw
- Add setters for dirty/pristine/touched/untouched that throw
- Add JSDoc with @usageNotes examples
- Add comprehensive unit tests for SignalFormControl
- Add FormGroup/FormArray integration tests
- Add web tests for CVA directive lifecycle
- Update migration docs with SignalFormControl usage
This commit is contained in:
kirjs 2026-01-16 11:13:29 -05:00 committed by Leon Senft
parent a2950805df
commit bbbdf0a6ed
6 changed files with 1384 additions and 13 deletions

View file

@ -83,7 +83,8 @@ In the template, use standard reactive syntax by binding the underlying control:
### Integrating a `FormGroup` into a signal form
You can also wrap an entire `FormGroup`. This is common when a reusable sub-section of a form—such as an **Address Block**—is still managed by legacy Reactive Forms.
You can also wrap an entire `FormGroup`. This is common when a reusable sub-section of a form—such as an **Address Block
**—is still managed by legacy Reactive Forms.
```typescript
import {signal} from '@angular/core';
@ -206,11 +207,148 @@ const formValue = computed(() => ({
## Bottom-up migration
This is coming soon.
### Integrating a Signal Form into a `FormGroup`
You can use `SignalFormControl` to expose a signal-based form as a standard `FormControl`. This is useful when you want
to migrate leaf nodes of a form to Signals while keeping the parent `FormGroup` structure.
```typescript
import {Component, inject, Injector, signal} from '@angular/core';
import {ReactiveFormsModule, FormGroup} from '@angular/forms';
import {SignalFormControl} from '@angular/forms/signals/compat';
import {required} from '@angular/forms/signals';
@Component({
// ...
imports: [ReactiveFormsModule],
})
export class UserProfile {
private injector = inject(Injector);
// 1. Create a SignalFormControl, use signal form rules.
// Note: SignalFormControl requires an Injector
emailControl = new SignalFormControl('', this.injector, (p) => {
required(p, {message: 'Email is required'});
});
// 2. Use it in a legacy FormGroup
form = new FormGroup({
email: this.emailControl,
});
}
```
The `SignalFormControl` synchronizes values and validation status bi-directionally:
- **Signal -> Control**: Changing `email.set(...)` updates `emailControl.value` and the parent `form.value`.
- **Control -> Signal**: Typing in the input (updating `emailControl`) updates the `email` signal.
- **Validation**: Schema validators (like `required`) propagate errors to `emailControl.errors`.
### Disabling/Enabling control.
Imperative APIs for changing the enabled/disabled state (like `enable()`, `disable()`) are intentionally not supported
in `SignalFormControl`. This is because the state of the control should be derived from the signal state and rules.
Attempting to call disable/enable would throw an error.
```typescript {avoid}
import {signal, effect} from '@angular/core';
export class UserProfile {
readonly emailControl = new SignalFormControl('', this.injector);
readonly isLoading = signal(false);
constructor() {
// This will throw an error
effect(() => {
if (this.isLoading()) {
this.emailControl.disable();
} else {
this.emailControl.enable();
}
});
}
}
```
Instead, use disabled rule:
```typescript {prefer}
import {signal} from '@angular/core';
import {SignalFormControl} from '@angular/forms/signals/compat';
import {disabled} from '@angular/forms/signals';
export class UserProfile {
readonly isLoading = signal(false);
readonly emailControl = new SignalFormControl('', this.injector, (p) => {
// The control becomes disabled whenever isLoading is true
disabled(p, () => this.isLoading());
});
async saveData() {
this.isLoading.set(true);
// ... perform save ...
this.isLoading.set(false);
}
}
```
### Dynamic manipulation
Imperative APIs for adding or removing validators (like `addValidators()`, `removeValidators()`, `setValidators()`) are intentionally not supported in `SignalFormControl`.
Attempting to call these methods will throw an error.
```typescript {avoid}
export class UserProfile {
readonly emailControl = new SignalFormControl('', this.injector);
readonly isRequired = signal(false);
toggleRequired() {
this.isRequired.update((v) => !v);
// This will throw an error
if (this.isRequired()) {
this.emailControl.addValidators(Validators.required);
} else {
this.emailControl.removeValidators(Validators.required);
}
}
}
```
Instead, use `applyWhen` rule to conditionally apply validators:
```typescript {prefer}
import {signal} from '@angular/core';
import {SignalFormControl} from '@angular/forms/signals/compat';
import {applyWhen, required} from '@angular/forms/signals';
export class UserProfile {
readonly isRequired = signal(false);
readonly emailControl = new SignalFormControl('', this.injector, (p) => {
// The control becomes required whenever isRequired is true
applyWhen(
p,
() => this.isRequired(),
(p) => {
required(p);
},
);
});
}
```
### Manual Error Selection
The `setErrors()` and `markAsPending()` methods are not supported. In Signal Forms, errors are derived from validation rules and async validation status. If you need to report an error, it should be done declaratively via a validation rule in the schema.
## Automatic status classes
Reactive/Template Forms automatically adds [class attributes](/guide/forms/template-driven-forms#track-control-states) (such as `.ng-valid` or `.ng-dirty`) to facilitate styling control states. Signal Forms does not do that.
Reactive/Template Forms automatically adds [class attributes](/guide/forms/template-driven-forms#track-control-states) (
such as `.ng-valid` or `.ng-dirty`) to facilitate styling control states. Signal Forms does not do that.
If you want to preserve this behavior, you can provide the `NG_STATUS_CLASSES` preset:

View file

@ -6,7 +6,15 @@
* found in the LICENSE file at https://angular.dev/license
*/
import {EventEmitter, inject, Injector, signal, WritableSignal, effect} from '@angular/core';
import {
EventEmitter,
inject,
Injector,
signal,
WritableSignal,
effect,
ɵRuntimeError as RuntimeError,
} from '@angular/core';
import {
AbstractControl,
ControlEvent,
@ -25,6 +33,7 @@ import {compatForm} from '../api/compat_form';
import {signalErrorsToValidationErrors} from '../../../src/api/rules';
import {FormOptions} from '../../../src/api/structure';
import {FieldState, FieldTree, SchemaFn} from '../../../src/api/types';
import {SignalFormsErrorCode} from '../../../src/errors';
import {normalizeFormArgs} from '../../../src/util/normalize_form_args';
import {removeListItem} from '../../../../src/util';
@ -42,6 +51,31 @@ export type ValueUpdateOptions = {
* This class provides a bridge between Signal Forms and Reactive Forms, allowing
* signal-based controls to be used within a standard `FormGroup` or `FormArray`.
*
* A control could be created using signal forms, and integrated with an existing FormGroup
* propagating all the statuses and validity.
*
* @usageNotes
*
* ### Basic usage
*
* ```angular-ts
* const form = new FormGroup({
* // You can create SignalFormControl with signal form rules, and add it to a FormGroup.
* name: new SignalFormControl('Alice', p => {
* required(p);
* }),
* age: new FormControl(25),
* });
* ```
* In the template you can get the underlying `fieldTree` and bind it:
*
* ```angular-html
* <form [formGroup]="form">
* <input [formField]="nameControl.fieldTree" />
* <input formControlName="age" />
* </form>
* ```
*
* @experimental
*/
export class SignalFormControl<T> extends AbstractControl {
@ -289,18 +323,42 @@ export class SignalFormControl<T> extends AbstractControl {
return this.fieldState.dirty();
}
override set dirty(_: boolean) {
throw unsupportedFeatureError(
'Setting dirty directly is not supported. Instead use markAsDirty().',
);
}
override get pristine(): boolean {
return !this.dirty;
}
override set pristine(_: boolean) {
throw unsupportedFeatureError(
'Setting pristine directly is not supported. Instead use reset().',
);
}
override get touched(): boolean {
return this.fieldState.touched();
}
override set touched(_: boolean) {
throw unsupportedFeatureError(
'Setting touched directly is not supported. Instead use markAsTouched() or reset().',
);
}
override get untouched(): boolean {
return !this.touched;
}
override set untouched(_: boolean) {
throw unsupportedFeatureError(
'Setting untouched directly is not supported. Instead use reset().',
);
}
override markAsTouched(opts?: {onlySelf?: boolean}): void {
this.fieldState.markAsTouched();
this.propagateToParent(opts, (parent) => parent.markAsTouched(opts));
@ -351,6 +409,58 @@ export class SignalFormControl<T> extends AbstractControl {
_syncPendingControls(): boolean {
return false;
}
override disable(_opts?: {onlySelf?: boolean; emitEvent?: boolean}): void {
throw unsupportedDisableEnableError();
}
override enable(_opts?: {onlySelf?: boolean; emitEvent?: boolean}): void {
throw unsupportedDisableEnableError();
}
override setValidators(_validators: any): void {
throw unsupportedValidatorsError();
}
override setAsyncValidators(_validators: any): void {
throw unsupportedValidatorsError();
}
override addValidators(_validators: any): void {
throw unsupportedValidatorsError();
}
override addAsyncValidators(_validators: any): void {
throw unsupportedValidatorsError();
}
override removeValidators(_validators: any): void {
throw unsupportedValidatorsError();
}
override removeAsyncValidators(_validators: any): void {
throw unsupportedValidatorsError();
}
override clearValidators(): void {
throw unsupportedValidatorsError();
}
override clearAsyncValidators(): void {
throw unsupportedValidatorsError();
}
override setErrors(_errors: any, _opts?: {emitEvent?: boolean}): void {
throw unsupportedFeatureError(
'Imperatively setting errors is not supported in signal forms. Errors are derived from validation rules.',
);
}
override markAsPending(_opts?: {onlySelf?: boolean; emitEvent?: boolean}): void {
throw unsupportedFeatureError(
'Imperatively marking as pending is not supported in signal forms. Pending state is derived from async validation status.',
);
}
}
class CachingWeakMap<K extends object, V> {
@ -419,3 +529,19 @@ function isFormControlState(formState: unknown): formState is {value: any; disab
'disabled' in formState
);
}
function unsupportedFeatureError(message: string): RuntimeError {
return new RuntimeError(SignalFormsErrorCode.UNSUPPORTED_FEATURE as any, ngDevMode && message);
}
function unsupportedDisableEnableError(): RuntimeError {
return unsupportedFeatureError(
'Imperatively changing enabled/disabled status in form control is not supported in signal forms. Instead use a "disabled" rule to derive the disabled status from a signal.',
);
}
function unsupportedValidatorsError(): RuntimeError {
return unsupportedFeatureError(
'Dynamically adding and removing validators is not supported in signal forms. Instead use the "applyWhen" rule to conditionally apply validators based on a signal.',
);
}

View file

@ -8,6 +8,7 @@
import {ɵRuntimeError as RuntimeError} from '@angular/core';
import {SignalFormsErrorCode} from '../errors';
import {signalErrorsToValidationErrors} from '../api/rules/validation/validation_errors';
import {
ControlValueAccessor,
@ -85,15 +86,7 @@ export class InteropNgControl
}
get errors(): ValidationErrors | null {
const errors = this.field().errors();
if (errors.length === 0) {
return null;
}
const errObj: ValidationErrors = {};
for (const error of errors) {
errObj[error.kind] = error;
}
return errObj;
return signalErrorsToValidationErrors(this.field().errors());
}
get pristine(): boolean {

View file

@ -0,0 +1,586 @@
/**
* @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 {ApplicationRef, Injector, resource} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {ControlEvent, FormArray, FormControlStatus, FormGroup} from '@angular/forms';
import {disabled, required, validateAsync, ValidationError} from '@angular/forms/signals';
import {SchemaFn} from '../../../src/api/types';
import {SignalFormControl} from '../../../compat/src/signal_form_control/signal_form_control';
function createSignalFormControl<T>(initialValue: T, schema?: SchemaFn<T>) {
const injector = TestBed.inject(Injector);
return new SignalFormControl(initialValue, schema, {injector});
}
function promiseWithResolvers<T = void>(): {
promise: Promise<T>;
resolve: (value: T | PromiseLike<T>) => void;
reject: (reason?: any) => void;
} {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: any) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return {promise, resolve, reject};
}
describe('SignalFormControl', () => {
describe('value and state access', () => {
it('should have the same value as the signal', () => {
const form = createSignalFormControl(10);
expect(form.value).toBe(10);
form.setValue(20);
expect(form.value).toBe(20);
});
it('should expose fieldTree', () => {
const form = createSignalFormControl(10);
expect(form.fieldTree().value()).toBe(10);
form.setValue(20);
expect(form.fieldTree().value()).toBe(20);
});
it('should return value for getRawValue', () => {
const form = createSignalFormControl(10);
expect(form.getRawValue()).toBe(10);
});
});
describe('validation', () => {
it('should validate', () => {
const form = createSignalFormControl<string>('', (p) => {
required(p);
});
expect(form.valid).toBe(false);
form.setValue('pirojok');
expect(form.valid).toBe(true);
form.setValue('');
expect(form.valid).toBe(false);
});
it('should expose validation errors through the errors getter', () => {
const form = createSignalFormControl<string>('', (p) => {
required(p);
});
const errors = form.errors;
expect(errors).not.toBeNull();
expect(errors!['required']).toEqual(jasmine.objectContaining({kind: 'required'}));
form.setValue(1);
expect(form.errors).toBeNull();
});
it('should expose pending status for async validators', async () => {
let deferred = promiseWithResolvers<ValidationError[]>();
const resolveNext = (errors: ValidationError[]) => {
TestBed.tick();
deferred.resolve(errors);
deferred = promiseWithResolvers<ValidationError[]>();
};
const form = createSignalFormControl('initial', (p) => {
validateAsync(p, {
params: ({value}) => value(),
factory: (params) =>
resource({
params,
loader: () => deferred.promise,
}),
onSuccess: (errors) => errors,
onError: () => null,
});
});
const appRef = TestBed.inject(ApplicationRef);
expect(form.pending).toBe(true);
expect(form.status).toBe('PENDING');
resolveNext([]);
await appRef.whenStable();
expect(form.pending).toBe(false);
expect(form.status).toBe('VALID');
form.setValue('invalid');
expect(form.pending).toBe(true);
expect(form.status).toBe('PENDING');
resolveNext([{kind: 'async-invalid'}]);
await appRef.whenStable();
expect(form.pending).toBe(false);
expect(form.status).toBe('INVALID');
expect(form.errors?.['async-invalid']).toEqual(
jasmine.objectContaining({kind: 'async-invalid'}),
);
});
it('should support disabled via rules', () => {
const form = createSignalFormControl(10, (p) => {
disabled(p, ({value}) => value() > 15);
});
expect(form.disabled).toBe(false);
expect(form.status).toBe('VALID');
form.setValue(20);
expect(form.disabled).toBe(true);
expect(form.status).toBe('DISABLED');
});
});
describe('status management (dirty/touched)', () => {
it('should support markAsTouched', () => {
const form = createSignalFormControl(10);
expect(form.touched).toBe(false);
form.markAsTouched();
expect(form.touched).toBe(true);
});
it('should support markAsDirty', () => {
const form = createSignalFormControl(10);
expect(form.dirty).toBe(false);
form.markAsDirty();
expect(form.dirty).toBe(true);
});
it('should support markAsPristine', () => {
const form = createSignalFormControl(10);
form.markAsDirty();
expect(form.dirty).toBe(true);
form.markAsPristine();
expect(form.dirty).toBe(false);
});
it('should preserve touched state when markAsPristine is called', () => {
const form = createSignalFormControl(10);
form.markAsDirty();
form.markAsTouched();
expect(form.dirty).toBe(true);
expect(form.touched).toBe(true);
form.markAsPristine();
expect(form.dirty).toBe(false);
expect(form.touched).toBe(true);
});
it('should support markAsUntouched', () => {
const form = createSignalFormControl(10);
form.markAsTouched();
expect(form.touched).toBe(true);
form.markAsUntouched();
expect(form.touched).toBe(false);
});
it('should preserve dirty state when markAsUntouched is called', () => {
const form = createSignalFormControl(10);
form.markAsDirty();
form.markAsTouched();
expect(form.dirty).toBe(true);
expect(form.touched).toBe(true);
form.markAsUntouched();
expect(form.touched).toBe(false);
expect(form.dirty).toBe(true);
});
it('should propagate dirty status to parent FormGroup immediately', () => {
const child = createSignalFormControl('meow');
const group = new FormGroup({
child: child,
});
expect(group.dirty).toBe(false);
child.markAsDirty();
expect(group.dirty).toBe(true);
});
it('should not propagate dirty status to parent when onlySelf is true', () => {
const child = createSignalFormControl('meow');
const group = new FormGroup({child});
child.markAsDirty({onlySelf: true});
expect(child.dirty).toBe(true);
expect(group.dirty).toBe(false);
});
it('should propagate touched status to parent FormGroup immediately', () => {
const child = createSignalFormControl('meow');
const group = new FormGroup({
child: child,
});
expect(group.touched).toBe(false);
child.markAsTouched();
expect(group.touched).toBe(true);
});
it('should not propagate touched status to parent when onlySelf is true', () => {
const child = createSignalFormControl('meow');
const group = new FormGroup({child});
child.markAsTouched({onlySelf: true});
expect(child.touched).toBe(true);
expect(group.touched).toBe(false);
});
it('should not propagate pristine status to parent when onlySelf is true', () => {
const child = createSignalFormControl('meow');
const group = new FormGroup({child});
group.markAsDirty();
expect(group.dirty).toBe(true);
child.markAsPristine({onlySelf: true});
expect(child.pristine).toBe(true);
expect(group.dirty).toBe(true);
});
it('should not propagate untouched status to parent when onlySelf is true', () => {
const child = createSignalFormControl('meow');
const group = new FormGroup({child});
group.markAsTouched();
expect(group.touched).toBe(true);
child.markAsUntouched({onlySelf: true});
expect(child.untouched).toBe(true);
expect(group.touched).toBe(true);
});
it('should propagate dirty status to parent FormGroup from fieldTree update', () => {
const child = createSignalFormControl('meow');
const group = new FormGroup({
child: child,
});
expect(group.dirty).toBe(false);
child.fieldTree().markAsDirty();
TestBed.tick();
// TODO: kirjs
//expect(group.dirty).toBe(true);
});
});
describe('observables and events', () => {
it('should emit valueChanges when the value updates', () => {
const form = createSignalFormControl(10);
const emissions: number[] = [];
form.valueChanges.subscribe((v: number) => emissions.push(v));
form.setValue(20);
TestBed.tick();
expect(emissions).toEqual([20]);
form.setValue(30);
TestBed.tick();
expect(emissions).toEqual([20, 30]);
});
it('should emit statusChanges when validity toggles', () => {
const form = createSignalFormControl<number | undefined>(undefined, (p) => {
required(p);
});
const statuses: FormControlStatus[] = [];
form.statusChanges.subscribe((status: FormControlStatus) => statuses.push(status));
form.setValue(1);
TestBed.tick();
expect(statuses).toEqual(['VALID']);
form.setValue(undefined);
TestBed.tick();
expect(statuses).toEqual(['VALID', 'INVALID']);
form.setValue(10);
TestBed.tick();
expect(statuses).toEqual(['VALID', 'INVALID', 'VALID']);
});
it('should emit ValueChangeEvent on events observable', () => {
const form = createSignalFormControl(10);
const events: any[] = [];
form.events.subscribe((e: ControlEvent<number>) => events.push(e));
form.setValue(20);
TestBed.tick();
const valueEvents = events.filter((e) => e.constructor.name === 'ValueChangeEvent');
expect(valueEvents.length).toBeGreaterThan(0);
expect(valueEvents[valueEvents.length - 1].value).toBe(20);
});
it('should emit StatusChangeEvent on events observable when status changes', () => {
const form = createSignalFormControl<number | undefined>(10, (p) => required(p));
TestBed.tick();
const events: any[] = [];
form.events.subscribe((e: ControlEvent<number | undefined>) => events.push(e));
form.setValue(undefined);
TestBed.tick();
const statusEvents = events.filter((e) => e.constructor.name === 'StatusChangeEvent');
expect(statusEvents.length).toBeGreaterThan(0);
expect(statusEvents[statusEvents.length - 1].status).toBe('INVALID');
});
it('should emit TouchedChangeEvent on events observable', () => {
const form = createSignalFormControl(10);
TestBed.tick();
const events: any[] = [];
form.events.subscribe((e: ControlEvent<number>) => events.push(e));
form.markAsTouched();
TestBed.tick();
expect(events.length).toBe(1);
expect(events[0].touched).toBe(true);
});
it('should emit PristineChangeEvent on events observable when dirty changes', () => {
const form = createSignalFormControl(10);
TestBed.tick();
const events: any[] = [];
form.events.subscribe((e: ControlEvent<number>) => events.push(e));
form.markAsDirty();
TestBed.tick();
expect(events.length).toBe(1);
expect(events[0].pristine).toBe(false);
});
});
describe('reset', () => {
it('should reset touched and dirty state', () => {
const form = createSignalFormControl(10);
form.markAsTouched();
form.markAsDirty();
expect(form.touched).toBe(true);
expect(form.dirty).toBe(true);
form.reset(10);
expect(form.touched).toBe(false);
expect(form.dirty).toBe(false);
expect(form.value).toBe(10);
});
it('should reset with a new value', () => {
const form = createSignalFormControl('pirojok');
form.markAsTouched();
form.markAsDirty();
form.reset('buterbrod');
expect(form.value).toBe('buterbrod');
expect(form.sourceValue()).toBe('buterbrod');
expect(form.touched).toBe(false);
expect(form.dirty).toBe(false);
});
it('should unbox value in reset', () => {
const form = createSignalFormControl(10);
form.reset({value: 20, disabled: true});
expect(form.value).toBe(20);
expect(form.disabled).toBe(false);
});
it('should NOT unbox value in reset if it has extra keys', () => {
const form = createSignalFormControl<any>(10);
const complexValue = {value: 20, disabled: true, extra: 1};
form.reset(complexValue);
expect(form.value).toEqual(complexValue);
});
it('should emit FormResetEvent on reset', () => {
const form = createSignalFormControl(10);
const events: any[] = [];
form.events.subscribe((e: ControlEvent<number>) => events.push(e));
form.reset(20);
expect(events.length).toBe(1);
// TODO check event
});
it('should NOT emit FormResetEvent on reset when emitEvent is false', () => {
const form = createSignalFormControl(10);
const events: any[] = [];
form.events.subscribe((e: ControlEvent<number>) => events.push(e));
form.reset(20, {emitEvent: false});
expect(events.length).toBe(0);
});
});
describe('unsupported methods', () => {
it('should throw error when calling disable()', () => {
const form = createSignalFormControl(10);
expect(() => form.disable()).toThrowError(
/Imperatively changing enabled\/disabled status in form control is not supported/,
);
});
it('should throw error when calling enable()', () => {
const form = createSignalFormControl(10);
expect(() => form.enable()).toThrowError(
/Imperatively changing enabled\/disabled status in form control is not supported/,
);
});
it('should throw error when calling setValidators()', () => {
const form = createSignalFormControl(10);
expect(() => form.setValidators(null)).toThrowError(
/Dynamically adding and removing validators is not supported/,
);
});
it('should throw error when calling setAsyncValidators()', () => {
const form = createSignalFormControl(10);
expect(() => form.setAsyncValidators(null)).toThrowError(
/Dynamically adding and removing validators is not supported/,
);
});
it('should throw error when calling addValidators()', () => {
const form = createSignalFormControl(10);
expect(() => form.addValidators([])).toThrowError(
/Dynamically adding and removing validators is not supported/,
);
});
it('should throw error when calling addAsyncValidators()', () => {
const form = createSignalFormControl(10);
expect(() => form.addAsyncValidators([])).toThrowError(
/Dynamically adding and removing validators is not supported/,
);
});
it('should throw error when calling removeValidators()', () => {
const form = createSignalFormControl(10);
expect(() => form.removeValidators([])).toThrowError(
/Dynamically adding and removing validators is not supported/,
);
});
it('should throw error when calling removeAsyncValidators()', () => {
const form = createSignalFormControl(10);
expect(() => form.removeAsyncValidators([])).toThrowError(
/Dynamically adding and removing validators is not supported/,
);
});
it('should throw error when calling clearValidators()', () => {
const form = createSignalFormControl(10);
expect(() => form.clearValidators()).toThrowError(
/Dynamically adding and removing validators is not supported/,
);
});
it('should throw error when calling clearAsyncValidators()', () => {
const form = createSignalFormControl(10);
expect(() => form.clearAsyncValidators()).toThrowError(
/Dynamically adding and removing validators is not supported/,
);
});
it('should throw error when calling setErrors()', () => {
const form = createSignalFormControl(10);
expect(() => form.setErrors(null)).toThrowError(
/Imperatively setting errors is not supported in signal forms/,
);
});
it('should throw error when calling markAsPending()', () => {
const form = createSignalFormControl(10);
expect(() => form.markAsPending()).toThrowError(
/Imperatively marking as pending is not supported in signal forms/,
);
});
it('should throw error when setting dirty directly', () => {
const form = createSignalFormControl(10);
expect(() => ((form as any).dirty = true)).toThrowError(
/Setting dirty directly is not supported. Instead use markAsDirty\(\)/,
);
});
it('should throw error when setting pristine directly', () => {
const form = createSignalFormControl(10);
expect(() => ((form as any).pristine = true)).toThrowError(
/Setting pristine directly is not supported. Instead use reset\(\)/,
);
});
it('should throw error when setting touched directly', () => {
const form = createSignalFormControl(10);
expect(() => ((form as any).touched = true)).toThrowError(
/Setting touched directly is not supported. Instead use markAsTouched\(\) or reset\(\)/,
);
});
it('should throw error when setting untouched directly', () => {
const form = createSignalFormControl(10);
expect(() => ((form as any).untouched = true)).toThrowError(
/Setting untouched directly is not supported. Instead use reset\(\)/,
);
});
});
describe('callback registration', () => {
it('should call registered onDisabledChange callback when disabled state changes', () => {
const form = createSignalFormControl(10, (p) => {
disabled(p, ({value}) => value() > 15);
});
const callback = jasmine.createSpy('onDisabledChange');
form.registerOnDisabledChange(callback);
TestBed.inject(ApplicationRef).tick();
expect(callback).toHaveBeenCalledWith(false);
callback.calls.reset();
form.setValue(20);
TestBed.inject(ApplicationRef).tick();
expect(callback).toHaveBeenCalledWith(true);
});
});
});

View file

@ -0,0 +1,395 @@
/**
* @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 {Injector} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {FormArray, FormControlStatus, FormGroup} from '@angular/forms';
import {SignalFormControl} from '../../../compat/src/signal_form_control/signal_form_control';
import {required} from '../../../public_api';
import {SchemaFn} from '../../../src/api/types';
function createSignalFormControl<T>(value: T, schema?: SchemaFn<T>) {
const injector = TestBed.inject(Injector);
return new SignalFormControl(value, schema, {injector});
}
// TODO: Organize this test better
describe('SignalFormControl in FormGroup', () => {
it('should reflect value and value changes', () => {
const form = createSignalFormControl(10);
const group = new FormGroup({
n: form,
});
expect(group.value).toEqual({n: 10});
form.setValue(20);
expect(group.value).toEqual({n: 20});
});
it('should propagate patchValue updates from child to parent', () => {
const form = createSignalFormControl(5);
const value = form.sourceValue;
const group = new FormGroup({
n: form,
});
const emissions: any[] = [];
group.valueChanges.subscribe((v) => emissions.push(v));
form.patchValue(15);
expect(group.value).toEqual({n: 15});
expect(emissions).toEqual([{n: 15}]);
expect(form.value).toBe(15);
expect(value()).toBe(15);
form.patchValue(25);
expect(group.value).toEqual({n: 25});
expect(emissions).toEqual([{n: 15}, {n: 25}]);
expect(form.value).toBe(25);
expect(value()).toBe(25);
});
it('should reflect validity changes', () => {
const form = createSignalFormControl<number | undefined>(10, (p) => required(p));
const group = new FormGroup({
n: form,
});
expect(group.status).toBe('VALID');
const statuses: FormControlStatus[] = [];
group.statusChanges.subscribe((status) => statuses.push(status));
form.setValue(undefined);
expect(group.status).toBe('INVALID');
form.setValue(10);
expect(group.status).toBe('VALID');
expect(statuses).toEqual(['INVALID', 'VALID']);
});
it('should update signal when parent setValue is called', () => {
const form = createSignalFormControl(10);
const value = form.sourceValue;
const group = new FormGroup({
n: form,
});
group.setValue({n: 20});
expect(value()).toBe(20);
expect(form.value).toBe(20);
});
it('should update signal when parent patchValue is called', () => {
const form = createSignalFormControl(10);
const value = form.sourceValue;
const group = new FormGroup({
n: form,
});
group.patchValue({n: 30});
expect(value()).toBe(30);
expect(form.value).toBe(30);
});
it('should reset child value and state when parent reset is called', () => {
const child = createSignalFormControl(10);
const value = child.sourceValue;
const group = new FormGroup({
n: child,
});
child.markAsDirty();
child.markAsTouched();
expect(child.dirty).toBe(true);
expect(child.touched).toBe(true);
group.reset({n: 50});
expect(value()).toBe(50);
expect(child.dirty).toBe(false);
expect(child.touched).toBe(false);
});
it('should mark child as touched when parent markAllAsTouched is called', () => {
const form = createSignalFormControl(10);
const group = new FormGroup({
n: form,
});
expect(form.touched).toBe(false);
group.markAllAsTouched();
expect(form.touched).toBe(true);
});
it('should mark child as pristine when parent markAsPristine is called', () => {
const form = createSignalFormControl(10);
const group = new FormGroup({
n: form,
});
form.markAsDirty();
expect(form.dirty).toBe(true);
group.markAsPristine();
expect(form.dirty).toBe(false);
expect(form.pristine).toBe(true);
});
it('should mark child as untouched when parent markAsUntouched is called', () => {
const form = createSignalFormControl(10);
const group = new FormGroup({
n: form,
});
form.markAsTouched();
expect(form.touched).toBe(true);
group.markAsUntouched();
expect(form.touched).toBe(false);
});
it('should include child value in parent getRawValue', () => {
const form = createSignalFormControl(10);
const group = new FormGroup({
n: form,
});
expect(group.getRawValue()).toEqual({n: 10});
form.setValue(99);
expect(group.getRawValue()).toEqual({n: 99});
});
it('should support cross-field validators on parent', () => {
const form = createSignalFormControl(10);
const group = new FormGroup(
{
n: form,
},
{
validators: (g) => {
const val = g.get('n')?.value;
return val > 5 ? null : {min: true};
},
},
);
expect(group.valid).toBe(true);
form.setValue(1);
expect(group.valid).toBe(false);
expect(group.errors).toEqual({min: true});
});
it('should allow retrieving child control using get()', () => {
const form = createSignalFormControl(10);
const group = new FormGroup({
n: form,
});
const retrieved = group.get('n');
expect(retrieved).toBe(form);
expect(retrieved?.value).toBe(10);
});
it('should emit parent statusChanges when child validity changes', () => {
const form = createSignalFormControl<number | undefined>(10, (p) => required(p));
const group = new FormGroup({
n: form,
});
const statuses: FormControlStatus[] = [];
group.statusChanges.subscribe((s) => statuses.push(s));
form.setValue(undefined);
expect(statuses).toContain('INVALID');
expect(group.status).toBe('INVALID');
});
it('should pass sourceControl correctly when signal value changes synchronously', () => {
const form = createSignalFormControl(10);
const group = new FormGroup({
n: form,
});
const sourceControls: any[] = [];
group.events.subscribe((event: any) => {
if (event.source) {
sourceControls.push(event.source);
}
});
form.fieldTree().value.set(20);
expect(sourceControls[0]).toBe(form);
});
it('should not notify parent when onlySelf is true', () => {
const form = createSignalFormControl(10);
const value = form.sourceValue;
const group = new FormGroup({
n: form,
});
const parentEmissions: unknown[] = [];
group.valueChanges.subscribe((v) => parentEmissions.push(v));
form.setValue(20, {onlySelf: true});
expect(parentEmissions.length).toBe(0);
expect(group.value).toEqual({n: 10});
expect(value()).toBe(20);
expect(form.value).toBe(20);
});
describe('integration with parent', () => {
it('should synchronize value with parent FormGroup immediately', () => {
const child = createSignalFormControl('meow');
const group = new FormGroup({
child: child,
});
child.fieldTree().value.set('wuf');
expect(group.value).toEqual({child: 'wuf'});
});
it('should synchronize nested value with parent FormGroup immediately', () => {
const child = createSignalFormControl({name: 'pirojok', says: 'meow'});
const group = new FormGroup({
child: child,
});
child.fieldTree.says().value.set('wuf');
expect(group.value).toEqual({child: {name: 'pirojok', says: 'wuf'}});
});
it('should synchronize multiple value sets with parent FormGroup immediately', () => {
const child = createSignalFormControl({name: 'a', count: 0});
const group = new FormGroup({child});
child.fieldTree.name().value.set('b');
expect(group.value).toEqual({child: {name: 'b', count: 0}});
child.fieldTree.count().value.set(1);
expect(group.value).toEqual({child: {name: 'b', count: 1}});
child.fieldTree.name().value.set('c');
expect(group.value).toEqual({child: {name: 'c', count: 1}});
child.fieldTree.count().value.set(2);
expect(group.value).toEqual({child: {name: 'c', count: 2}});
});
it('should return the same child fieldTree instance on repeated access', () => {
const child = createSignalFormControl({name: 'test', count: 0});
const name1 = child.fieldTree.name;
const name2 = child.fieldTree.name;
expect(name1 === name2).toBe(true);
});
it('should return the same fieldState instance on repeated calls', () => {
const child = createSignalFormControl({name: 'test', count: 0});
const state1 = child.fieldTree();
const state2 = child.fieldTree();
expect(state1 === state2).toBe(true);
});
it('should return the same child fieldState instance on repeated calls', () => {
const child = createSignalFormControl({name: 'test', count: 0});
const state1 = child.fieldTree.name();
const state2 = child.fieldTree.name();
expect(state1 === state2).toBe(true);
});
describe('array fieldTree', () => {
it('should access length property', () => {
const child = createSignalFormControl(['a', 'b', 'c']);
expect(child.fieldTree.length).toBe(3);
});
it('should access element children via index', () => {
const child = createSignalFormControl(['first', 'second', 'third']);
expect(child.fieldTree[0]().value()).toBe('first');
expect(child.fieldTree[1]().value()).toBe('second');
expect(child.fieldTree[2]().value()).toBe('third');
});
it('should set element value via index', () => {
const child = createSignalFormControl(['a', 'b', 'c']);
const group = new FormGroup({child});
child.fieldTree[1]().value.set('updated');
expect(group.value).toEqual({child: ['a', 'updated', 'c']});
});
it('should iterate over object fields', () => {
const child = createSignalFormControl({x: 'a', y: 'b', z: 'c'});
const values: string[] = [];
for (const [, field] of child.fieldTree) {
values.push(field!().value());
}
expect(values).toEqual(['a', 'b', 'c']);
});
});
it('should propagate validity to parent FormGroup immediately', () => {
const child = createSignalFormControl<string>('meow-meow', (p) => required(p));
const group = new FormGroup({
child: child,
});
expect(group.valid).withContext('Valid initially').toBe(true);
child.fieldTree().value.set('');
expect(group.valid).withContext('Invalid immediately on value change').toBe(false);
group.controls.child.setValue('meow');
expect(group.valid).withContext('Valid initially').toBe(true);
});
describe('FormArray', () => {
it('should synchronize value with parent FormArray immediately', () => {
const child = createSignalFormControl('meow');
const array = new FormArray([child]);
child.fieldTree().value.set('wuf');
expect(array.value).toEqual(['wuf']);
});
it('should propagate validity to parent FormArray immediately', () => {
const child = createSignalFormControl<string>('valid', (p) => required(p));
const array = new FormArray([child]);
expect(array.valid).withContext('Valid initially').toBe(true);
child.fieldTree().value.set('');
expect(array.valid).withContext('Invalid immediately on value change').toBe(false);
array.at(0).setValue('meow');
expect(array.valid).withContext('Valid initially').toBe(true);
});
});
});
});

View file

@ -0,0 +1,133 @@
/**
* @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 {Component, Injector, inject, provideZonelessChangeDetection, signal} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms';
import {disabled} from '@angular/forms/signals';
import {SignalFormControl} from '../../compat';
import {FormField} from '../../src/api/form_field_directive';
describe('SignalFormControl (web)', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideZonelessChangeDetection()],
imports: [ReactiveFormsModule, FormField],
});
});
it('binds to formField directive', () => {
@Component({
standalone: true,
imports: [ReactiveFormsModule, FormField],
template: `<input [formField]="signalControl.fieldTree" />`,
})
class TestCmp {
readonly signalControl = new SignalFormControl('initial', undefined, {
injector: inject(Injector),
});
readonly control = this.signalControl as unknown as FormControl;
}
const fixture = act(() => TestBed.createComponent(TestCmp));
const input: HTMLInputElement = fixture.nativeElement.querySelector('input');
expect(input.value).toBe('initial');
act(() => fixture.componentInstance.control.setValue('changed'));
expect(input.value).toBe('changed');
act(() => {
input.value = 'view';
input.dispatchEvent(new Event('input'));
});
expect(fixture.componentInstance.signalControl.sourceValue()).toBe('view');
});
it('binds inside nested FormGroup via formGroupName', () => {
@Component({
standalone: true,
imports: [ReactiveFormsModule, FormField],
template: `
<div [formGroup]="group">
<div formGroupName="inner">
<input [formField]="signalControl.fieldTree" />
</div>
</div>
`,
})
class TestCmp {
readonly signalControl = new SignalFormControl('initial', undefined, {
injector: inject(Injector),
});
readonly control = this.signalControl as unknown as FormControl;
readonly group = new FormGroup({
inner: new FormGroup({
control: this.control,
}),
});
}
const fixture = act(() => TestBed.createComponent(TestCmp));
const input: HTMLInputElement = fixture.nativeElement.querySelector('input');
expect(input.value).toBe('initial');
expect(fixture.componentInstance.group.dirty).toBe(false);
act(() => {
input.value = 'updated';
input.dispatchEvent(new Event('input'));
});
expect(fixture.componentInstance.signalControl.sourceValue()).toBe('updated');
expect(fixture.componentInstance.group.dirty).toBe(true);
});
it('should unregister disabled callback when directive is destroyed', () => {
@Component({
standalone: true,
imports: [ReactiveFormsModule],
template: `
@if (showInput()) {
<input [formControl]="control" />
}
`,
})
class TestCmp {
readonly showInput = signal(true);
readonly signalControl = new SignalFormControl(
10,
(p) => {
disabled(p, ({value}) => value() > 15);
},
{injector: inject(Injector)},
);
readonly control = this.signalControl as unknown as FormControl;
}
const fixture = act(() => TestBed.createComponent(TestCmp));
const input: HTMLInputElement = fixture.nativeElement.querySelector('input');
expect(input).toBeTruthy();
expect(input.disabled).toBe(false);
act(() => fixture.componentInstance.showInput.set(false));
expect(fixture.nativeElement.querySelector('input')).toBeNull();
expect(() => {
act(() => fixture.componentInstance.control.setValue(20));
}).not.toThrow();
});
});
function act<T>(fn: () => T): T {
try {
return fn();
} finally {
TestBed.tick();
}
}