diff --git a/goldens/public-api/forms/signals/compat/index.api.md b/goldens/public-api/forms/signals/compat/index.api.md index 77222616858d..fc3c5bbd257b 100644 --- a/goldens/public-api/forms/signals/compat/index.api.md +++ b/goldens/public-api/forms/signals/compat/index.api.md @@ -5,15 +5,12 @@ ```ts import { AbstractControl } from '@angular/forms'; -import { ControlValueAccessor } from '@angular/forms'; import { EventEmitter } from '@angular/core'; import { FormControlState } from '@angular/forms'; import { FormControlStatus } from '@angular/forms'; import * as i0 from '@angular/core'; import { Injector } from '@angular/core'; import { Signal } from '@angular/core'; -import { ValidationErrors } from '@angular/forms'; -import { ValidatorFn } from '@angular/forms'; import { WritableSignal } from '@angular/core'; // @public diff --git a/goldens/public-api/forms/signals/index.api.md b/goldens/public-api/forms/signals/index.api.md index 019e94e907ed..ff0a6701c970 100644 --- a/goldens/public-api/forms/signals/index.api.md +++ b/goldens/public-api/forms/signals/index.api.md @@ -5,9 +5,7 @@ ```ts import { AbstractControl } from '@angular/forms'; -import { ControlValueAccessor } from '@angular/forms'; import { DebounceTimer } from '@angular/core'; -import { FormControlStatus } from '@angular/forms'; import { HttpResourceOptions } from '@angular/common/http'; import { HttpResourceRequest } from '@angular/common/http'; import * as i0 from '@angular/core'; @@ -21,8 +19,6 @@ import { Provider } from '@angular/core'; import { ResourceRef } from '@angular/core'; import { Signal } from '@angular/core'; import { StandardSchemaV1 } from '@standard-schema/spec'; -import { ValidationErrors } from '@angular/forms'; -import { ValidatorFn } from '@angular/forms'; import { WritableSignal } from '@angular/core'; // @public @@ -175,7 +171,6 @@ export class FormField { readonly field: i0.InputSignal>; focus(options?: FocusOptions): void; readonly injector: Injector; - protected get interopNgControl(): InteropNgControl; registerAsBinding(bindingOptions?: FormFieldBindingOptions): void; readonly state: Signal>; // (undocumented) diff --git a/packages/forms/signals/src/controls/interop_ng_control.ts b/packages/forms/signals/src/controls/interop_ng_control.ts index 5771fd596820..e3e0a6efbaa9 100644 --- a/packages/forms/signals/src/controls/interop_ng_control.ts +++ b/packages/forms/signals/src/controls/interop_ng_control.ts @@ -66,7 +66,8 @@ export class InteropNgControl implements CombinedControl { readonly control: AbstractControl = this as unknown as AbstractControl; get value(): any { - return this.field().value(); + // CVA controls are not aware of user debouncing and will expect `NgControl.value` to reflect their latest writes. + return this.field().controlValue(); } get valid(): boolean { diff --git a/packages/forms/signals/src/directive/control_cva.ts b/packages/forms/signals/src/directive/control_cva.ts index 13ecdb970fc2..8d9cff89cfac 100644 --- a/packages/forms/signals/src/directive/control_cva.ts +++ b/packages/forms/signals/src/directive/control_cva.ts @@ -6,7 +6,17 @@ * found in the LICENSE file at https://angular.dev/license */ -import {untracked, type ɵControlDirectiveHost as ControlDirectiveHost} from '@angular/core'; +import { + computed, + signal, + untracked, + type Signal, + type WritableSignal, + type ɵControlDirectiveHost as ControlDirectiveHost, +} from '@angular/core'; +import {NG_VALIDATORS, type Validator, Validators, type ValidatorFn} from '@angular/forms'; +import {reactiveErrorsToSignalErrors} from '../compat/validation_errors'; +import {type ValidationError} from '../api/rules'; import { bindingUpdated, CONTROL_BINDING_NAMES, @@ -17,6 +27,10 @@ import { import {setNativeDomProperty} from './native'; import type {FormField} from './form_field'; +function isValidatorObject(v: Function | Validator): v is Validator { + return typeof v === 'object' && v !== null; +} + export function cvaControlCreate( host: ControlDirectiveHost, parent: FormField, @@ -31,6 +45,39 @@ export function cvaControlCreate( parent.state().controlValue.set(value as any); }); parent.controlValueAccessor!.registerOnTouched(() => parent.state().markAsTouched()); + + const legacyValidators = parent.injector.get(NG_VALIDATORS, null, {optional: true, self: true}); + if (legacyValidators) { + let version: WritableSignal | undefined; + + for (const v of legacyValidators) { + if (isValidatorObject(v) && v.registerOnValidatorChange) { + version ??= signal(0); + v.registerOnValidatorChange(() => { + version!.update((n) => n + 1); + }); + } + } + + const validatorFns = legacyValidators.map((v) => + typeof v === 'function' ? (v as ValidatorFn) : v.validate.bind(v), + ); + const mergedValidator = Validators.compose(validatorFns); + + const parseErrors = computed(() => { + // Read the `version` signal to re-run the validator when legacy validators trigger their change callbacks. + version?.(); + const errors = mergedValidator ? mergedValidator(parent.interopNgControl.control) : null; + return reactiveErrorsToSignalErrors(errors, parent.interopNgControl.control); + }); + // We must cast here because `CompatValidationError` claims to have `fieldTree` statically (to + // satisfy `ValidationState` elsewhere), but at construction it is created without it and acts as + // `WithoutFieldTree` initially. + parent.parseErrorsSource.set( + parseErrors as unknown as Signal, + ); + } + parent.registerAsBinding(); return () => { diff --git a/packages/forms/signals/src/directive/form_field.ts b/packages/forms/signals/src/directive/form_field.ts index 4f7b5c0a5c6c..6721269ac6d6 100644 --- a/packages/forms/signals/src/directive/form_field.ts +++ b/packages/forms/signals/src/directive/form_field.ts @@ -27,6 +27,7 @@ import { } from '@angular/core'; import { type ControlValueAccessor, + NG_VALIDATORS, NG_VALUE_ACCESSOR, NgControl, ɵFORM_FIELD_PARSE_ERRORS as FORM_FIELD_PARSE_ERRORS, @@ -156,7 +157,8 @@ export class FormField { private readonly config = inject(SIGNAL_FORMS_CONFIG, {optional: true}); private readonly validityMonitor = inject(InputValidityMonitor); - private readonly parseErrorsSource = signal< + /** @internal */ + readonly parseErrorsSource = signal< Signal | undefined >(undefined); @@ -164,7 +166,8 @@ export class FormField { private _interopNgControl: InteropNgControl | undefined; /** Lazily instantiates a fake `NgControl` for this form field. */ - protected get interopNgControl(): InteropNgControl { + /** @internal */ + get interopNgControl(): InteropNgControl { return (this._interopNgControl ??= new InteropNgControl(this.state)); } diff --git a/packages/forms/signals/test/web/interop.spec.ts b/packages/forms/signals/test/web/interop.spec.ts index 1b09ef9c1d01..4166738f80b7 100644 --- a/packages/forms/signals/test/web/interop.spec.ts +++ b/packages/forms/signals/test/web/interop.spec.ts @@ -10,6 +10,7 @@ import { ChangeDetectionStrategy, Component, Directive, + forwardRef, inject, input, provideZonelessChangeDetection, @@ -19,11 +20,15 @@ import { } from '@angular/core'; import {TestBed} from '@angular/core/testing'; import { + AbstractControl, ControlValueAccessor, DefaultValueAccessor, + NG_VALIDATORS, NG_VALUE_ACCESSOR, NgControl, ReactiveFormsModule, + ValidationErrors, + Validator, } from '@angular/forms'; import { debounce, @@ -165,6 +170,141 @@ describe('ControlValueAccessor', () => { expect(control.writeCount).toBe(1); // Should still be 1 (No write-back!) }); + it('propagates parse errors from legacy NG_VALIDATORS to field', () => { + const legacyErrors = signal(null); + + @Component({ + selector: 'legacy-control-with-validators', + template: ``, + providers: [ + {provide: NG_VALUE_ACCESSOR, useExisting: LegacyControlWithValidators, multi: true}, + {provide: NG_VALIDATORS, useExisting: LegacyControlWithValidators, multi: true}, + ], + }) + class LegacyControlWithValidators implements ControlValueAccessor, Validator { + value = ''; + + private onChangeFn?: (value: string) => void; + + writeValue(newValue: string): void { + this.value = newValue; + } + + registerOnChange(fn: (value: string) => void): void { + this.onChangeFn = fn; + } + + registerOnTouched(fn: () => void): void {} + + validate(control: AbstractControl): ValidationErrors | null { + return legacyErrors(); + } + + onInput(newValue: string) { + this.value = newValue; + this.onChangeFn?.(newValue); + } + } + + @Component({ + imports: [LegacyControlWithValidators, FormField], + template: ``, + }) + class TestCmp { + readonly f = form(signal('test')); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const field = fixture.componentInstance.f; + + expect(field().errors()).toEqual([]); + + act(() => legacyErrors.set({'legacy-parse': {text: 'bad'}})); + expect(field().errors()).toEqual([ + jasmine.objectContaining({ + kind: 'legacy-parse', + context: {text: 'bad'}, + }), + ]); + }); + + it('should re-evaluate parse errors when registerOnValidatorChange is called', () => { + const legacyErrors = signal(null); + let validatorComponent: any = null; + + @Component({ + selector: 'legacy-control-with-on-validator-change', + template: ``, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => LegacyControlWithOnValidatorChange), + multi: true, + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => LegacyControlWithOnValidatorChange), + multi: true, + }, + ], + }) + class LegacyControlWithOnValidatorChange implements ControlValueAccessor, Validator { + value = ''; + private onChangeFn?: (value: string | null) => void; + validatorOnChange?: () => void; + + constructor() { + validatorComponent = this; + } + + writeValue(newValue: string): void { + this.value = newValue; + } + + registerOnChange(fn: (value: string | null) => void): void { + this.onChangeFn = fn; + } + + registerOnTouched(fn: () => void): void {} + + validate(control: AbstractControl): ValidationErrors | null { + return legacyErrors(); + } + + registerOnValidatorChange(fn: () => void): void { + this.validatorOnChange = fn; + } + + onInput(newValue: string) { + this.value = newValue; + this.onChangeFn?.(null); // Keep value null + } + } + + @Component({ + imports: [LegacyControlWithOnValidatorChange, FormField], + template: ``, + }) + class TestCmp { + f = form(signal(null)); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const field = fixture.componentInstance.f; + + expect(field().errors()).toEqual([]); + + act(() => legacyErrors.set({'legacy-parse': {text: 'bad'}})); + act(() => validatorComponent.validatorOnChange?.()); // Force recalculation! + + expect(field().errors()).toEqual([ + jasmine.objectContaining({ + kind: 'legacy-parse', + context: {text: 'bad'}, + }), + ]); + }); + it('should support debounce', async () => { const {promise, resolve} = promiseWithResolvers(); @@ -398,6 +538,68 @@ describe('ControlValueAccessor', () => { expect(customControlInstance.writeCount).toBe(2); // 1 initial + 1 update }); + it('should reflect latest written value in NgControl.value when debounce is active', () => { + let ngControlValueInsideCva: string | null = null; + + @Component({ + selector: 'custom-control-with-debounce', + template: ``, + }) + class CustomControlWithDebounce implements ControlValueAccessor { + ngControl = inject(NgControl); + value = ''; + private onChangeFn?: (value: string) => void; + + constructor() { + this.ngControl.valueAccessor = this; + } + + writeValue(newValue: string): void { + this.value = newValue; + } + + registerOnChange(fn: (value: string) => void): void { + this.onChangeFn = fn; + } + + registerOnTouched(fn: () => void): void {} + + onInput(newValue: string) { + this.value = newValue; + this.onChangeFn?.(newValue); + ngControlValueInsideCva = this.ngControl.value; + } + } + + @Component({ + imports: [CustomControlWithDebounce, FormField], + template: ``, + }) + class TestCmp { + readonly f = form(signal('initial'), (p) => { + debounce(p, 'blur'); + }); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const field = fixture.componentInstance.f; + + expect(field().value()).toBe('initial'); + + const debugEl = fixture.debugElement.query( + (el) => el.componentInstance instanceof CustomControlWithDebounce, + ); + const cvaInstance = debugEl.componentInstance; + + act(() => cvaInstance.onInput('updated')); + + // NgControl.value should be 'updated' inside onInput! + expect(ngControlValueInsideCva as unknown as string).toBe('updated'); + + // But the field value should still be 'initial' if it hasn't been flushed yet! + expect(field().value()).toBe('initial'); + }); + describe('properties', () => { describe('disabled', () => { it('should bind to directive input', () => {