angular/packages/forms/signals/test/node/validation_status.spec.ts
Miles Malerba b8314bd340 feat(forms): add experimental signal-based forms (#63408)
This commit introduces an experimental version of a new signal-based forms API for Angular. This new API aims to explore how signals can be leveraged to create a more declarative, intuitive, and reactive way of handling forms.

The primary goals of this new signal-based approach are:

*   **Signal-centric Design:** Place signals at the core of the forms experience, enabling a truly reactive programming model for form state and logic.
*   **Declarative Logic:** Allow developers to define form behavior, such as validation and conditional fields, declaratively using TypeScript. This moves logic out of the template and into a typed, testable schema.
*   **Developer-Owned Data Model:** The library does not maintain a copy of data in a form model, but instead read and write it via a developer-provided `WritableSignal`, eliminating the need for applications to synchronize their data with the form system.
*   **Interoperability:** A key design goal is seamless interoperability with existing reactive forms, allowing for incremental adoption.
*   **Bridging Template and Reactive Forms:** This exploration hopes to close the gap between template and reactive forms, offering a unified and more powerful approach that combines the best aspects of both.

This initial version of the experimental API includes the core building blocks, such as the `form()` function, `Field` and `FieldState` objects, and a `[control]` directive for binding to UI elements. It also introduces a schema-based system for defining validation, conditional logic, and other form behaviors.

Note: This is an early, experimental API. It is not yet complete and is subject to change based on feedback and further exploration.

Co-authored-by: Kirill Cherkashin <kirts@google.com>
Co-authored-by: Alex Rickabaugh <alxhub@users.noreply.github.com>
Co-authored-by: Leon Senft <leonsenft@users.noreply.github.com>
Co-authored-by: Dylan Hunn <dylhunn@gmail.com>
Co-authored-by: Michael Small <michael-small@users.noreply.github.com>

PR Close #63408
2025-08-28 09:02:43 -07:00

587 lines
16 KiB
TypeScript

/**
* @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, resource, signal} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {
customError,
Field,
form,
NgValidationError,
patternError,
requiredError,
validate,
validateAsync,
validateTree,
ValidationError,
type WithoutField,
} from '../../public_api';
function validateValue(value: string): WithoutField<ValidationError>[] {
return value === 'INVALID' ? [customError()] : [];
}
function validateValueForChild(
value: string,
field: Field<unknown> | undefined,
): ValidationError[] {
return value === 'INVALID' ? [customError({field})] : [];
}
async function waitFor(fn: () => boolean, count = 100): Promise<void> {
while (!fn()) {
await new Promise((resolve) => setTimeout(resolve, 1));
if (--count === 0) {
throw Error('waitFor timeout');
}
}
}
describe('validation status', () => {
let injector: Injector;
let appRef: ApplicationRef;
beforeEach(() => {
injector = TestBed.inject(Injector);
appRef = TestBed.inject(ApplicationRef);
});
describe('single-field validator', () => {
it('should affect field validity', () => {
const f = form(
signal('VALID'),
(p) => {
validate(p, ({value}) => validateValue(value()));
},
{injector},
);
expect(f().valid()).toBe(true);
expect(f().invalid()).toBe(false);
f().value.set('INVALID');
expect(f().valid()).toBe(false);
expect(f().invalid()).toBe(true);
});
it('validity should flow from child to parent', () => {
const f = form(
signal({child: 'VALID'}),
(p) => {
validate(p.child, ({value}) => validateValue(value()));
},
{injector},
);
expect(f().valid()).toBe(true);
expect(f().invalid()).toBe(false);
f.child().value.set('INVALID');
expect(f().valid()).toBe(false);
expect(f().invalid()).toBe(true);
});
it('validity should not flow from parent to child', () => {
const f = form(
signal({child: 'VALID'}),
(p) => {
validate(p, ({value}) => validateValue(value().child));
},
{injector},
);
expect(f.child().valid()).toBe(true);
expect(f.child().invalid()).toBe(false);
f.child().value.set('INVALID');
expect(f.child().valid()).toBe(true);
expect(f.child().invalid()).toBe(false);
});
});
describe('tree validator', () => {
it('should affect validity of host field if no target specified', () => {
const f = form(
signal('VALID'),
(p) => {
validateTree(p, ({value}) => validateValueForChild(value(), undefined));
},
{injector},
);
expect(f().valid()).toBe(true);
expect(f().invalid()).toBe(false);
f().value.set('INVALID');
expect(f().valid()).toBe(false);
expect(f().invalid()).toBe(true);
});
it('should affect validity of targeted field', () => {
const f = form(
signal({child: 'VALID'}),
(p) => {
validateTree(p, ({value, fieldOf}) =>
validateValueForChild(value().child, fieldOf(p.child)),
);
},
{injector},
);
expect(f.child().valid()).toBe(true);
expect(f.child().invalid()).toBe(false);
f.child().value.set('INVALID');
expect(f.child().valid()).toBe(false);
expect(f.child().invalid()).toBe(true);
});
it('validity should flow from child to parent', () => {
const f = form(
signal({child: 'VALID'}),
(p) => {
validateTree(p, ({value, fieldOf}) =>
validateValueForChild(value().child, fieldOf(p.child)),
);
},
{injector},
);
expect(f().valid()).toBe(true);
expect(f().invalid()).toBe(false);
f.child().value.set('INVALID');
expect(f().valid()).toBe(false);
expect(f().invalid()).toBe(true);
});
it('should not affect sibling validity', () => {
const f = form(
signal({child: 'VALID', sibling: ''}),
(p) => {
validateTree(p, ({value, fieldOf}) =>
validateValueForChild(value().child, fieldOf(p.child)),
);
},
{injector},
);
expect(f.sibling().valid()).toBe(true);
expect(f.sibling().invalid()).toBe(false);
f.child().value.set('INVALID');
expect(f.sibling().valid()).toBe(true);
expect(f.sibling().invalid()).toBe(false);
});
});
describe('async validator', () => {
it('should affect validity of host field if no target specified', async () => {
let res: Resource<unknown>;
const f = form(
signal('VALID'),
(p) => {
validateAsync(p, {
params: ({value}) => value(),
factory: (params) =>
(res = resource({
params,
loader: ({params}) =>
new Promise<ValidationError[]>((r) =>
setTimeout(() => r(validateValueForChild(params, undefined))),
),
})),
errors: (errs) => errs,
});
},
{injector},
);
await waitFor(() => res?.isLoading());
expect(f().pending()).toBe(true);
expect(f().valid()).toBe(false);
expect(f().invalid()).toBe(false);
await appRef.whenStable();
expect(f().pending()).toBe(false);
expect(f().valid()).toBe(true);
expect(f().invalid()).toBe(false);
f().value.set('INVALID');
await waitFor(() => res?.isLoading());
expect(f().pending()).toBe(true);
expect(f().valid()).toBe(false);
expect(f().invalid()).toBe(false);
await appRef.whenStable();
expect(f().pending()).toBe(false);
expect(f().valid()).toBe(false);
expect(f().invalid()).toBe(true);
});
it('should affect validity of targeted field', async () => {
let res: Resource<unknown>;
const f = form(
signal({child: 'VALID'}),
(p) => {
validateAsync(p, {
params: ({value}) => value().child,
factory: (params) =>
(res = resource({
params,
loader: ({params}) =>
new Promise<ValidationError[]>((r) =>
setTimeout(() => r(validateValueForChild(params, undefined))),
),
})),
errors: (errs, {fieldOf}) =>
errs.map((e) => ({
...e,
field: fieldOf(p.child),
})),
});
},
{injector},
);
await waitFor(() => res?.isLoading());
expect(f.child().pending()).toBe(true);
expect(f.child().valid()).toBe(false);
expect(f.child().invalid()).toBe(false);
await appRef.whenStable();
expect(f.child().pending()).toBe(false);
expect(f.child().valid()).toBe(true);
expect(f.child().invalid()).toBe(false);
f.child().value.set('INVALID');
await waitFor(() => res?.isLoading());
expect(f.child().pending()).toBe(true);
expect(f.child().valid()).toBe(false);
expect(f.child().invalid()).toBe(false);
await appRef.whenStable();
expect(f.child().pending()).toBe(false);
expect(f.child().valid()).toBe(false);
expect(f.child().invalid()).toBe(true);
});
it('validity should flow from child to parent', async () => {
let res: Resource<unknown>;
const f = form(
signal({child: 'VALID'}),
(p) => {
validateAsync(p, {
params: ({value}) => value().child,
factory: (params) =>
(res = resource({
params,
loader: ({params}) =>
new Promise<ValidationError[]>((r) =>
setTimeout(() => r(validateValueForChild(params, undefined))),
),
})),
errors: (errs, {fieldOf}) =>
errs.map((e) => ({
...e,
field: fieldOf(p.child),
})),
});
},
{injector},
);
await waitFor(() => res?.isLoading());
expect(f().pending()).toBe(true);
expect(f().valid()).toBe(false);
expect(f().invalid()).toBe(false);
await appRef.whenStable();
expect(f().pending()).toBe(false);
expect(f().valid()).toBe(true);
expect(f().invalid()).toBe(false);
f.child().value.set('INVALID');
await waitFor(() => res?.isLoading());
expect(f().pending()).toBe(true);
expect(f().valid()).toBe(false);
expect(f().invalid()).toBe(false);
await appRef.whenStable();
expect(f().pending()).toBe(false);
expect(f().valid()).toBe(false);
expect(f().invalid()).toBe(true);
});
it('pending should flow from parent to child', async () => {
// We can't guarantee the parent won't assign a tree error to the sibling field, so the
// sibling must inherit the pending state from the parent.
let res: Resource<unknown>;
const f = form(
signal({child: 'VALID', sibling: ''}),
(p) => {
validateAsync(p, {
params: ({value}) => value().child,
factory: (params) =>
(res = resource({
params,
loader: ({params}) =>
new Promise<ValidationError[]>((r) =>
setTimeout(() => r(validateValueForChild(params, undefined))),
),
})),
errors: (errs, {fieldOf}) =>
errs.map((e) => ({
...e,
field: fieldOf(p.child),
})),
});
},
{injector},
);
await waitFor(() => res?.isLoading());
expect(f.sibling().pending()).toBe(true);
expect(f.sibling().valid()).toBe(false);
expect(f.sibling().invalid()).toBe(false);
await appRef.whenStable();
expect(f.sibling().pending()).toBe(false);
expect(f.sibling().valid()).toBe(true);
expect(f.sibling().invalid()).toBe(false);
f.child().value.set('INVALID');
await waitFor(() => res?.isLoading());
expect(f.sibling().pending()).toBe(true);
expect(f.sibling().valid()).toBe(false);
expect(f.sibling().invalid()).toBe(false);
await appRef.whenStable();
expect(f.sibling().pending()).toBe(false);
expect(f.sibling().valid()).toBe(true);
expect(f.sibling().invalid()).toBe(false);
});
it('parent should be pending/invalid while child is pending/invalid', async () => {
let res: Resource<unknown>;
const f = form(
signal({child: 'VALID'}),
(p) => {
validateAsync(p.child, {
params: ({value}) => value(),
factory: (params) =>
(res = resource({
params,
loader: ({params}) =>
new Promise<ValidationError[]>((r) =>
setTimeout(() => r(validateValueForChild(params, undefined))),
),
})),
errors: (errs) => errs,
});
},
{injector},
);
await waitFor(() => res?.isLoading());
expect(f().pending()).toBe(true);
expect(f().valid()).toBe(false);
expect(f().invalid()).toBe(false);
await appRef.whenStable();
expect(f().pending()).toBe(false);
expect(f().valid()).toBe(true);
expect(f().invalid()).toBe(false);
f.child().value.set('INVALID');
await waitFor(() => res?.isLoading());
expect(f().pending()).toBe(true);
expect(f().valid()).toBe(false);
expect(f().invalid()).toBe(false);
await appRef.whenStable();
expect(f().pending()).toBe(false);
expect(f().valid()).toBe(false);
expect(f().invalid()).toBe(true);
});
});
describe('multiple validators', () => {
it('should be invalid status when validators are mix of valid and invalid', () => {
const f = form(
signal('MIXED'),
(p) => {
validate(p, () => []);
validate(p, () => [customError()]);
},
{injector},
);
expect(f().pending()).toBe(false);
expect(f().valid()).toBe(false);
expect(f().invalid()).toBe(true);
});
it('should be pending status when validators are mix of valid and pending', async () => {
let res: Resource<unknown>;
const f = form(
signal('MIXED'),
(p) => {
validate(p, () => []);
validateAsync(p, {
params: () => [],
factory: (params) =>
(res = resource({
params,
loader: () => new Promise<ValidationError[]>((r) => setTimeout(() => r([]))),
})),
errors: (errs) => errs,
});
},
{injector},
);
await waitFor(() => res?.isLoading());
expect(f().pending()).toBe(true);
expect(f().valid()).toBe(false);
expect(f().invalid()).toBe(false);
});
it('should be invalid status when validators are mix of invalid and pending', async () => {
let res: Resource<unknown>;
let res2: Resource<unknown>;
const f = form(
signal('MIXED'),
(p) => {
validateAsync(p, {
params: () => [],
factory: (params) =>
(res = resource({
params,
loader: () =>
new Promise<ValidationError[]>((r) => setTimeout(() => r([customError()]))),
})),
errors: (errs) => errs,
});
validateAsync(p, {
params: () => [],
factory: (params) =>
(res2 = resource({
params,
loader: () => new Promise<ValidationError[]>((r) => setTimeout(() => r([]), 10)),
})),
errors: (errs) => errs,
});
},
{injector},
);
await waitFor(() => !res?.isLoading() && res2.isLoading());
expect(f().pending()).toBe(true);
expect(f().valid()).toBe(false);
expect(f().invalid()).toBe(true);
});
it('should be invalid status when validators are mix of valid, invalid, and pending', async () => {
let res: Resource<unknown>;
let res2: Resource<unknown>;
const f = form(
signal('MIXED'),
(p) => {
validate(p, () => []);
validateAsync(p, {
params: () => [],
factory: (params) =>
(res = resource({
params,
loader: () =>
new Promise<ValidationError[]>((r) => setTimeout(() => r([customError()]))),
})),
errors: (errs) => errs,
});
validateAsync(p, {
params: () => [],
factory: (params) =>
(res2 = resource({
params,
loader: () => new Promise<ValidationError[]>((r) => setTimeout(() => r([]), 10)),
})),
errors: (errs) => errs,
});
},
{injector},
);
await waitFor(() => !res?.isLoading() && res2.isLoading());
expect(f().pending()).toBe(true);
expect(f().valid()).toBe(false);
expect(f().invalid()).toBe(true);
});
});
describe('NgValidationError', () => {
it('instanceof should check if structure matches a standard error type', () => {
const e1 = requiredError();
expect(e1 instanceof NgValidationError).toBe(true);
const e2 = customError({kind: 'min', min: 'two'});
expect(e2 instanceof NgValidationError).toBe(false);
const e3 = patternError(/.*@.*\.com/);
expect(e3 instanceof NgValidationError).toBe(true);
});
it('instanceof should narrow the type to a discriminated union', () => {
const e: unknown = undefined;
if (e instanceof NgValidationError) {
e.message;
switch (e.kind) {
case 'min':
e.min;
break;
case 'standardSchema':
e.issue;
break;
// @ts-expect-error
case 'fakekind':
break;
}
}
// Just so we have an expectation in the test,
// the real goal is to test the type narrowing above.
expect(true).toBe(true);
});
});
});