From bbbdf0a6ed62899f486e7d7652345b9844d4a7dc Mon Sep 17 00:00:00 2001 From: kirjs Date: Fri, 16 Jan 2026 11:13:29 -0500 Subject: [PATCH] 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 --- .../content/guide/forms/signals/migration.md | 144 ++++- .../signal_form_control.ts | 128 +++- .../src/controls/interop_ng_control.ts | 11 +- .../node/compat/signal_form_control.spec.ts | 586 ++++++++++++++++++ .../signal_form_control_in_form_group.spec.ts | 395 ++++++++++++ .../test/web/signal_form_control_web.spec.ts | 133 ++++ 6 files changed, 1384 insertions(+), 13 deletions(-) create mode 100644 packages/forms/signals/test/node/compat/signal_form_control.spec.ts create mode 100644 packages/forms/signals/test/node/compat/signal_form_control_in_form_group.spec.ts create mode 100644 packages/forms/signals/test/web/signal_form_control_web.spec.ts diff --git a/adev/src/content/guide/forms/signals/migration.md b/adev/src/content/guide/forms/signals/migration.md index 5188f9ea6db..89f170125b1 100644 --- a/adev/src/content/guide/forms/signals/migration.md +++ b/adev/src/content/guide/forms/signals/migration.md @@ -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: diff --git a/packages/forms/signals/compat/src/signal_form_control/signal_form_control.ts b/packages/forms/signals/compat/src/signal_form_control/signal_form_control.ts index e219c7bedb1..a63f7d6d72b 100644 --- a/packages/forms/signals/compat/src/signal_form_control/signal_form_control.ts +++ b/packages/forms/signals/compat/src/signal_form_control/signal_form_control.ts @@ -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 + *
+ * + * + *
+ * ``` + * * @experimental */ export class SignalFormControl extends AbstractControl { @@ -289,18 +323,42 @@ export class SignalFormControl 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 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 { @@ -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.', + ); +} diff --git a/packages/forms/signals/src/controls/interop_ng_control.ts b/packages/forms/signals/src/controls/interop_ng_control.ts index c8f60ee4843..80153d34bcb 100644 --- a/packages/forms/signals/src/controls/interop_ng_control.ts +++ b/packages/forms/signals/src/controls/interop_ng_control.ts @@ -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 { diff --git a/packages/forms/signals/test/node/compat/signal_form_control.spec.ts b/packages/forms/signals/test/node/compat/signal_form_control.spec.ts new file mode 100644 index 00000000000..d34f62d60a0 --- /dev/null +++ b/packages/forms/signals/test/node/compat/signal_form_control.spec.ts @@ -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(initialValue: T, schema?: SchemaFn) { + const injector = TestBed.inject(Injector); + return new SignalFormControl(initialValue, schema, {injector}); +} + +function promiseWithResolvers(): { + promise: Promise; + resolve: (value: T | PromiseLike) => void; + reject: (reason?: any) => void; +} { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: any) => void; + const promise = new Promise((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('', (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('', (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(); + const resolveNext = (errors: ValidationError[]) => { + TestBed.tick(); + deferred.resolve(errors); + deferred = promiseWithResolvers(); + }; + + 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(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) => 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(10, (p) => required(p)); + + TestBed.tick(); + + const events: any[] = []; + form.events.subscribe((e: ControlEvent) => 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) => 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) => 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(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) => 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) => 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); + }); + }); +}); diff --git a/packages/forms/signals/test/node/compat/signal_form_control_in_form_group.spec.ts b/packages/forms/signals/test/node/compat/signal_form_control_in_form_group.spec.ts new file mode 100644 index 00000000000..c2e00a0c46b --- /dev/null +++ b/packages/forms/signals/test/node/compat/signal_form_control_in_form_group.spec.ts @@ -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(value: T, schema?: SchemaFn) { + 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(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(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('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('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); + }); + }); + }); +}); diff --git a/packages/forms/signals/test/web/signal_form_control_web.spec.ts b/packages/forms/signals/test/web/signal_form_control_web.spec.ts new file mode 100644 index 00000000000..a06514865a3 --- /dev/null +++ b/packages/forms/signals/test/web/signal_form_control_web.spec.ts @@ -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: ``, + }) + 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: ` +
+
+ +
+
+ `, + }) + 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()) { + + } + `, + }) + 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(fn: () => T): T { + try { + return fn(); + } finally { + TestBed.tick(); + } +}