mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
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
587 lines
16 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|