Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions goldens/public-api/forms/signals/compat/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 0 additions & 5 deletions goldens/public-api/forms/signals/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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
Expand Down Expand Up @@ -175,7 +171,6 @@ export class FormField<T> {
readonly field: i0.InputSignal<Field<T>>;
focus(options?: FocusOptions): void;
readonly injector: Injector;
protected get interopNgControl(): InteropNgControl;
registerAsBinding(bindingOptions?: FormFieldBindingOptions): void;
readonly state: Signal<FieldState<T, string | number>>;
// (undocumented)
Expand Down
3 changes: 2 additions & 1 deletion packages/forms/signals/src/controls/interop_ng_control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ export class InteropNgControl implements CombinedControl {
readonly control: AbstractControl<any, any> = this as unknown as AbstractControl<any, any>;

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 {
Expand Down
49 changes: 48 additions & 1 deletion packages/forms/signals/src/directive/control_cva.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<unknown>,
Expand All @@ -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<number> | 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<readonly ValidationError.WithoutFieldTree[]>,
);
}

parent.registerAsBinding();

return () => {
Expand Down
7 changes: 5 additions & 2 deletions packages/forms/signals/src/directive/form_field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -156,15 +157,17 @@ export class FormField<T> {
private readonly config = inject(SIGNAL_FORMS_CONFIG, {optional: true});
private readonly validityMonitor = inject(InputValidityMonitor);

private readonly parseErrorsSource = signal<
/** @internal */
readonly parseErrorsSource = signal<
Signal<readonly ValidationError.WithoutFieldTree[]> | undefined
>(undefined);

/** A lazily instantiated fake `NgControl`. */
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));
}

Expand Down
202 changes: 202 additions & 0 deletions packages/forms/signals/test/web/interop.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
ChangeDetectionStrategy,
Component,
Directive,
forwardRef,
inject,
input,
provideZonelessChangeDetection,
Expand All @@ -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,
Expand Down Expand Up @@ -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<ValidationErrors | null>(null);

@Component({
selector: 'legacy-control-with-validators',
template: `<input [value]="value" (input)="onInput($event.target.value)" />`,
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: `<legacy-control-with-validators [formField]="f" />`,
})
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<ValidationErrors | null>(null);
let validatorComponent: any = null;

@Component({
selector: 'legacy-control-with-on-validator-change',
template: `<input [value]="value" (input)="onInput($event.target.value)" />`,
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: `<legacy-control-with-on-validator-change [formField]="f" />`,
})
class TestCmp {
f = form<string | null>(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<void>();

Expand Down Expand Up @@ -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: `<input [value]="value" (input)="onInput($event.target.value)" />`,
})
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: `<custom-control-with-debounce [formField]="f" />`,
})
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', () => {
Expand Down
Loading