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
422 lines
13 KiB
TypeScript
422 lines
13 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 {signal} from '@angular/core';
|
|
import {FieldContext, FieldState, customError} from '../../public_api';
|
|
import {DYNAMIC} from '../../src/schema/logic';
|
|
import {LogicNodeBuilder} from '../../src/schema/logic_node';
|
|
|
|
const fakeFieldContext: FieldContext<unknown> = {
|
|
fieldOf: () => undefined!,
|
|
stateOf: <P>() =>
|
|
({
|
|
context: undefined,
|
|
structure: {pathKeys: () => [], parent: undefined},
|
|
}) as unknown as FieldState<P>,
|
|
valueOf: () => undefined!,
|
|
field: undefined!,
|
|
state: undefined!,
|
|
value: undefined!,
|
|
};
|
|
|
|
describe('LogicNodeBuilder', () => {
|
|
it('should build logic', () => {
|
|
// (p) => {
|
|
// validate(p, () => ({kind: 'root-err'}));
|
|
// };
|
|
|
|
const builder = LogicNodeBuilder.newRoot();
|
|
builder.addSyncErrorRule(() => [customError({kind: 'root-err'})]);
|
|
|
|
const logicNode = builder.build();
|
|
expect(logicNode.logic.syncErrors.compute(fakeFieldContext)).toEqual([
|
|
customError({kind: 'root-err'}),
|
|
]);
|
|
});
|
|
|
|
it('should build child logic', () => {
|
|
// (p) => {
|
|
// validate(p.a, () => ({kind: 'child-err'}));
|
|
// };
|
|
|
|
const builder = LogicNodeBuilder.newRoot();
|
|
builder.getChild('a').addSyncErrorRule(() => [customError({kind: 'root-err'})]);
|
|
|
|
const logicNode = builder.build();
|
|
expect(logicNode.getChild('a').logic.syncErrors.compute(fakeFieldContext)).toEqual([
|
|
customError({kind: 'root-err'}),
|
|
]);
|
|
});
|
|
|
|
it('should build merged logic', () => {
|
|
// (p) => {
|
|
// validate(p, () => ({kind: 'err-1'}));
|
|
// validate(p, () => ({kind: 'err-2'}));
|
|
// };
|
|
|
|
const builder = LogicNodeBuilder.newRoot();
|
|
builder.addSyncErrorRule(() => [customError({kind: 'err-1'})]);
|
|
|
|
const builder2 = LogicNodeBuilder.newRoot();
|
|
builder2.addSyncErrorRule(() => [customError({kind: 'err-2'})]);
|
|
builder.mergeIn(builder2);
|
|
|
|
const logicNode = builder.build();
|
|
expect(logicNode.logic.syncErrors.compute(fakeFieldContext)).toEqual([
|
|
customError({kind: 'err-1'}),
|
|
customError({kind: 'err-2'}),
|
|
]);
|
|
});
|
|
|
|
it('should build merged child logic', () => {
|
|
// (p) => {
|
|
// validate(p.a, () => ({kind: 'err-1'}));
|
|
// validate(p.a, () => ({kind: 'err-2'}));
|
|
// };
|
|
|
|
const builder = LogicNodeBuilder.newRoot();
|
|
builder.getChild('a').addSyncErrorRule(() => [customError({kind: 'err-1'})]);
|
|
|
|
const builder2 = LogicNodeBuilder.newRoot();
|
|
builder2.getChild('a').addSyncErrorRule(() => [customError({kind: 'err-2'})]);
|
|
builder.mergeIn(builder2);
|
|
|
|
const logicNode = builder.build();
|
|
expect(logicNode.getChild('a').logic.syncErrors.compute(fakeFieldContext)).toEqual([
|
|
customError({kind: 'err-1'}),
|
|
customError({kind: 'err-2'}),
|
|
]);
|
|
});
|
|
|
|
it('should build logic with predicate', () => {
|
|
// (p) => {
|
|
// applyWhen(p, pred, (p) => {
|
|
// validate(p, () => ({kind: 'err-1'}));
|
|
// });
|
|
// }
|
|
|
|
const builder = LogicNodeBuilder.newRoot();
|
|
|
|
const pred = signal(true);
|
|
const builder2 = LogicNodeBuilder.newRoot();
|
|
builder2.addSyncErrorRule(() => [customError({kind: 'err-1'})]);
|
|
builder.mergeIn(builder2, {fn: () => pred(), path: undefined!});
|
|
|
|
const logicNode = builder.build();
|
|
expect(logicNode.logic.syncErrors.compute(fakeFieldContext)).toEqual([
|
|
customError({kind: 'err-1'}),
|
|
]);
|
|
|
|
pred.set(false);
|
|
expect(logicNode.logic.syncErrors.compute(fakeFieldContext)).toEqual([]);
|
|
});
|
|
|
|
it('should apply predicate to merged in logic', () => {
|
|
// (p) => {
|
|
// applyWhen(p, pred, (p) => {
|
|
// apply(p, (p) => {
|
|
// validate(p, () => ({kind: 'err-1'}));
|
|
// });
|
|
// });
|
|
// }
|
|
|
|
const builder = LogicNodeBuilder.newRoot();
|
|
|
|
const pred = signal(true);
|
|
const builder2 = LogicNodeBuilder.newRoot();
|
|
|
|
const builder3 = LogicNodeBuilder.newRoot();
|
|
builder3.addSyncErrorRule(() => [customError({kind: 'err-1'})]);
|
|
|
|
builder2.mergeIn(builder3);
|
|
builder.mergeIn(builder2, {fn: () => pred(), path: undefined!});
|
|
|
|
const logicNode = builder.build();
|
|
expect(logicNode.logic.syncErrors.compute(fakeFieldContext)).toEqual([
|
|
customError({kind: 'err-1'}),
|
|
]);
|
|
|
|
pred.set(false);
|
|
expect(logicNode.logic.syncErrors.compute(fakeFieldContext)).toEqual([]);
|
|
});
|
|
|
|
it('should apply predicate to merged in child logic', () => {
|
|
// (p) => {
|
|
// applyWhen(p, pred, (p) => {
|
|
// apply(p, (p) => {
|
|
// validate(p.a, () => ({kind: 'err-1'}));
|
|
// });
|
|
// });
|
|
// }
|
|
|
|
const builder = LogicNodeBuilder.newRoot();
|
|
|
|
const pred = signal(true);
|
|
const builder2 = LogicNodeBuilder.newRoot();
|
|
|
|
const builder3 = LogicNodeBuilder.newRoot();
|
|
builder3.getChild('a').addSyncErrorRule(() => [customError({kind: 'err-1'})]);
|
|
|
|
builder2.mergeIn(builder3);
|
|
builder.mergeIn(builder2, {fn: () => pred(), path: undefined!});
|
|
|
|
const logicNode = builder.build();
|
|
expect(logicNode.getChild('a').logic.syncErrors.compute(fakeFieldContext)).toEqual([
|
|
customError({kind: 'err-1'}),
|
|
]);
|
|
|
|
pred.set(false);
|
|
expect(logicNode.getChild('a').logic.syncErrors.compute(fakeFieldContext)).toEqual([]);
|
|
});
|
|
|
|
it('should combine predicates', () => {
|
|
// (p) => {
|
|
// applyWhen(p, pred, (p) => {
|
|
// applyWhen(p.a, pred2, (a) => {
|
|
// validate(a, () => ({kind: 'err-1'}));
|
|
// });
|
|
// });
|
|
// }
|
|
|
|
const builder = LogicNodeBuilder.newRoot();
|
|
|
|
const pred = signal(true);
|
|
const builder2 = LogicNodeBuilder.newRoot();
|
|
|
|
const pred2 = signal(true);
|
|
const builder3 = LogicNodeBuilder.newRoot();
|
|
builder3.addSyncErrorRule(() => [customError({kind: 'err-1'})]);
|
|
|
|
builder2.getChild('a').mergeIn(builder3, {fn: () => pred2(), path: undefined!});
|
|
builder.mergeIn(builder2, {fn: () => pred(), path: undefined!});
|
|
|
|
const logicNode = builder.build();
|
|
expect(logicNode.getChild('a').logic.syncErrors.compute(fakeFieldContext)).toEqual([
|
|
customError({kind: 'err-1'}),
|
|
]);
|
|
|
|
pred.set(false);
|
|
pred2.set(true);
|
|
expect(logicNode.getChild('a').logic.syncErrors.compute(fakeFieldContext)).toEqual([]);
|
|
|
|
pred.set(true);
|
|
pred2.set(false);
|
|
expect(logicNode.getChild('a').logic.syncErrors.compute(fakeFieldContext)).toEqual([]);
|
|
});
|
|
|
|
it('should propagate predicates through deep application', () => {
|
|
// (p) => {
|
|
// applyWhen(p, pred, (p) => {
|
|
// validate(p.a.b, () => ({kind: 'err-1'}));
|
|
// applyWhen(p.a, pred2, (a) => {
|
|
// validate(a.b, () => ({kind: 'err-2'}));
|
|
// applyWhen(a.b, pred3, (b) => {
|
|
// validate(b, () => ({kind: 'err-3'}));
|
|
// });
|
|
// });
|
|
// });
|
|
// }
|
|
|
|
const builder = LogicNodeBuilder.newRoot();
|
|
|
|
const pred = signal(true);
|
|
const builder2 = LogicNodeBuilder.newRoot();
|
|
builder2
|
|
.getChild('a')
|
|
.getChild('b')
|
|
.addSyncErrorRule(() => [customError({kind: 'err-1'})]);
|
|
|
|
const pred2 = signal(true);
|
|
const builder3 = LogicNodeBuilder.newRoot();
|
|
builder3.getChild('b').addSyncErrorRule(() => [customError({kind: 'err-2'})]);
|
|
|
|
const pred3 = signal(true);
|
|
const builder4 = LogicNodeBuilder.newRoot();
|
|
builder4.addSyncErrorRule(() => [customError({kind: 'err-3'})]);
|
|
builder3.getChild('b').mergeIn(builder4, {fn: () => pred3(), path: undefined!});
|
|
builder2.getChild('a').mergeIn(builder3, {fn: () => pred2(), path: undefined!});
|
|
builder.mergeIn(builder2, {fn: () => pred(), path: undefined!});
|
|
|
|
const logicNode = builder.build();
|
|
expect(
|
|
logicNode.getChild('a').getChild('b').logic.syncErrors.compute(fakeFieldContext),
|
|
).toEqual([
|
|
customError({kind: 'err-1'}),
|
|
customError({kind: 'err-2'}),
|
|
customError({kind: 'err-3'}),
|
|
]);
|
|
|
|
pred.set(true);
|
|
pred2.set(true);
|
|
pred3.set(false);
|
|
expect(
|
|
logicNode.getChild('a').getChild('b').logic.syncErrors.compute(fakeFieldContext),
|
|
).toEqual([customError({kind: 'err-1'}), customError({kind: 'err-2'})]);
|
|
|
|
pred.set(true);
|
|
pred2.set(false);
|
|
pred3.set(true);
|
|
expect(
|
|
logicNode.getChild('a').getChild('b').logic.syncErrors.compute(fakeFieldContext),
|
|
).toEqual([customError({kind: 'err-1'})]);
|
|
|
|
pred.set(false);
|
|
pred2.set(true);
|
|
pred3.set(true);
|
|
expect(
|
|
logicNode.getChild('a').getChild('b').logic.syncErrors.compute(fakeFieldContext),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it('should propagate predicates through deep child access', () => {
|
|
// (p) => {
|
|
// applyWhen(p, pred, (p) => {
|
|
// applyEach(p.items, (i) => {
|
|
// validate(i.last, () => ({kind: 'err-1'}));
|
|
// });
|
|
// });
|
|
// };
|
|
|
|
const builder = LogicNodeBuilder.newRoot();
|
|
|
|
const pred = signal(true);
|
|
const builder2 = LogicNodeBuilder.newRoot();
|
|
|
|
const builder3 = LogicNodeBuilder.newRoot();
|
|
builder3.getChild('last').addSyncErrorRule(() => [customError({kind: 'err-1'})]);
|
|
|
|
builder2.getChild('items').getChild(DYNAMIC).mergeIn(builder3);
|
|
builder.mergeIn(builder2, {fn: () => pred(), path: undefined!});
|
|
|
|
const logicNode = builder.build();
|
|
expect(
|
|
logicNode
|
|
.getChild('items')
|
|
.getChild(DYNAMIC)
|
|
.getChild('last')
|
|
.logic.syncErrors.compute(fakeFieldContext),
|
|
).toEqual([customError({kind: 'err-1'})]);
|
|
|
|
pred.set(false);
|
|
expect(
|
|
logicNode
|
|
.getChild('items')
|
|
.getChild(DYNAMIC)
|
|
.getChild('last')
|
|
.logic.syncErrors.compute(fakeFieldContext),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it('should preserve ordering across merges', () => {
|
|
// (p) => {
|
|
// validate(p, () => ({kind: 'err-1'}));
|
|
// apply(p, (p) => {
|
|
// validate(p, () => ({kind: 'err-2'}));
|
|
// })
|
|
// validate(p, () => ({kind: 'err-3'}));
|
|
// };
|
|
|
|
const builder = LogicNodeBuilder.newRoot();
|
|
builder.addSyncErrorRule(() => [customError({kind: 'err-1'})]);
|
|
|
|
const builder2 = LogicNodeBuilder.newRoot();
|
|
builder2.addSyncErrorRule(() => [customError({kind: 'err-2'})]);
|
|
builder.mergeIn(builder2);
|
|
|
|
builder.addSyncErrorRule(() => [customError({kind: 'err-3'})]);
|
|
|
|
const logicNode = builder.build();
|
|
expect(logicNode.logic.syncErrors.compute(fakeFieldContext)).toEqual([
|
|
customError({kind: 'err-1'}),
|
|
customError({kind: 'err-2'}),
|
|
customError({kind: 'err-3'}),
|
|
]);
|
|
});
|
|
|
|
it('should preserve child ordering across merges', () => {
|
|
// (p) => {
|
|
// validate(p.a, () => ({kind: 'err-1'}));
|
|
// apply(p, (p) => {
|
|
// validate(p.a, () => ({kind: 'err-2'}));
|
|
// })
|
|
// validate(p.a, () => ({kind: 'err-3'}));
|
|
// };
|
|
|
|
const builder = LogicNodeBuilder.newRoot();
|
|
builder.getChild('a').addSyncErrorRule(() => [customError({kind: 'err-1'})]);
|
|
|
|
const builder2 = LogicNodeBuilder.newRoot();
|
|
builder2.getChild('a').addSyncErrorRule(() => [customError({kind: 'err-2'})]);
|
|
builder.mergeIn(builder2);
|
|
|
|
builder.getChild('a').addSyncErrorRule(() => [customError({kind: 'err-3'})]);
|
|
|
|
const logicNode = builder.build();
|
|
expect(logicNode.getChild('a').logic.syncErrors.compute(fakeFieldContext)).toEqual([
|
|
customError({kind: 'err-1'}),
|
|
customError({kind: 'err-2'}),
|
|
customError({kind: 'err-3'}),
|
|
]);
|
|
});
|
|
|
|
it('should support circular logic structures', () => {
|
|
// const s = schema((p) => {
|
|
// validate(p, () => ({kind: 'err-1'})),
|
|
// apply(p.next, s);
|
|
// }));
|
|
|
|
const builder = LogicNodeBuilder.newRoot();
|
|
builder.addSyncErrorRule(() => [customError({kind: 'err-1'})]);
|
|
builder.getChild('next').mergeIn(builder);
|
|
|
|
const logicNode = builder.build();
|
|
expect(logicNode.logic.syncErrors.compute(fakeFieldContext)).toEqual([
|
|
customError({kind: 'err-1'}),
|
|
]);
|
|
expect(logicNode.getChild('next').logic.syncErrors.compute(fakeFieldContext)).toEqual([
|
|
customError({kind: 'err-1'}),
|
|
]);
|
|
expect(
|
|
logicNode.getChild('next').getChild('next').logic.syncErrors.compute(fakeFieldContext),
|
|
).toEqual([customError({kind: 'err-1'})]);
|
|
});
|
|
|
|
it('should support circular logic structures with predicate', () => {
|
|
// const s = schema((p) => {
|
|
// validate(p, () => ({kind: 'err-1'})),
|
|
// applyWhen(p.next, pred, s);
|
|
// }));
|
|
|
|
const pred = signal(true);
|
|
const builder = LogicNodeBuilder.newRoot();
|
|
builder.addSyncErrorRule(() => [customError({kind: 'err-1'})]);
|
|
builder.getChild('next').mergeIn(builder, {fn: () => pred(), path: undefined!});
|
|
|
|
const logicNode = builder.build();
|
|
expect(logicNode.logic.syncErrors.compute(fakeFieldContext)).toEqual([
|
|
customError({kind: 'err-1'}),
|
|
]);
|
|
expect(logicNode.getChild('next').logic.syncErrors.compute(fakeFieldContext)).toEqual([
|
|
customError({kind: 'err-1'}),
|
|
]);
|
|
expect(
|
|
logicNode.getChild('next').getChild('next').logic.syncErrors.compute(fakeFieldContext),
|
|
).toEqual([customError({kind: 'err-1'})]);
|
|
|
|
// TODO: test that verifies that the same predicate can resolve with a different field context
|
|
// on `.next` vs on `.next.next`
|
|
pred.set(false);
|
|
expect(logicNode.logic.syncErrors.compute(fakeFieldContext)).toEqual([
|
|
customError({kind: 'err-1'}),
|
|
]);
|
|
expect(logicNode.getChild('next').logic.syncErrors.compute(fakeFieldContext)).toEqual([]);
|
|
expect(
|
|
logicNode.getChild('next').getChild('next').logic.syncErrors.compute(fakeFieldContext),
|
|
).toEqual([]);
|
|
});
|
|
});
|