mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
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:
parent
a2950805df
commit
bbbdf0a6ed
6 changed files with 1384 additions and 13 deletions
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
133
packages/forms/signals/test/web/signal_form_control_web.spec.ts
Normal file
133
packages/forms/signals/test/web/signal_form_control_web.spec.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue