diff --git a/packages/forms/signals/src/field/field_adapter.ts b/packages/forms/signals/src/field/field_adapter.ts index b0d08933be2..f29d64739de 100644 --- a/packages/forms/signals/src/field/field_adapter.ts +++ b/packages/forms/signals/src/field/field_adapter.ts @@ -8,12 +8,12 @@ import {FieldPathNode} from '../schema/path_node'; +import {WritableSignal} from '@angular/core'; import {FormFieldManager} from './manager'; import {FieldNode} from './node'; import {FieldNodeState} from './state'; import {ChildFieldNodeOptions, FieldNodeOptions, FieldNodeStructure} from './structure'; -import {ValidationState, FieldValidationState} from './validation'; -import {WritableSignal} from '@angular/core'; +import {FieldValidationState, ValidationState} from './validation'; /** * Adapter allowing customization of the creation logic for a field and its associated diff --git a/packages/forms/signals/src/schema/path_node.ts b/packages/forms/signals/src/schema/path_node.ts index 386fd6c2bc0..be1cda241ac 100644 --- a/packages/forms/signals/src/schema/path_node.ts +++ b/packages/forms/signals/src/schema/path_node.ts @@ -37,14 +37,33 @@ export class FieldPathNode { FIELD_PATH_PROXY_HANDLER, ) as unknown as FieldPath; + /** + * For a root path node this will contain the root logic builder. For non-root nodes, + * they determine their logic builder from their parent so this is undefined. + */ + private readonly logicBuilder: LogicNodeBuilder | undefined; + protected constructor( /** The property keys used to navigate from the root path to this path. */ readonly keys: PropertyKey[], - /** The logic builder used to accumulate logic on this path node. */ - readonly logic: LogicNodeBuilder, - root: FieldPathNode, + root: FieldPathNode | undefined, + /** The parent of this path node. */ + private readonly parent: FieldPathNode | undefined, + /** The key of this node in its parent. */ + private readonly keyInParent: PropertyKey | undefined, ) { this.root = root ?? this; + if (!parent) { + this.logicBuilder = LogicNodeBuilder.newRoot(); + } + } + + /** The logic builder used to accumulate logic on this path node. */ + get logic(): LogicNodeBuilder { + if (this.logicBuilder) { + return this.logicBuilder; + } + return this.parent!.logic.getChild(this.keyInParent!); } /** @@ -60,10 +79,7 @@ export class FieldPathNode { */ getChild(key: PropertyKey): FieldPathNode { if (!this.children.has(key)) { - this.children.set( - key, - new FieldPathNode([...this.keys, key], this.logic.getChild(key), this.root), - ); + this.children.set(key, new FieldPathNode([...this.keys, key], this.root, this, key)); } return this.children.get(key)!; } @@ -85,7 +101,7 @@ export class FieldPathNode { /** Creates a new root path node to be passed in to a schema function. */ static newRoot() { - return new FieldPathNode([], LogicNodeBuilder.newRoot(), undefined!); + return new FieldPathNode([], undefined, undefined, undefined); } } diff --git a/packages/forms/signals/test/node/api/structure.spec.ts b/packages/forms/signals/test/node/api/structure.spec.ts new file mode 100644 index 00000000000..8d38a3ea1ec --- /dev/null +++ b/packages/forms/signals/test/node/api/structure.spec.ts @@ -0,0 +1,53 @@ +/** + * @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, signal} from '@angular/core'; +import {TestBed} from '@angular/core/testing'; +import {apply, form, required, schema} from '@angular/forms/signals'; + +describe('structure APIs', () => { + describe('apply', () => { + it('should maintain order when applying to child of root path', () => { + const s = schema<{a: string}>((p) => { + required(p.a, {message: 'before'}); + apply(p.a, (prop) => { + required(prop, {message: 'apply'}); + }); + required(p.a, {message: 'after'}); + }); + + const data = signal({a: ''}); + const f = form(data, s, {injector: TestBed.inject(Injector)}); + + expect(f.a().errors()).toEqual([ + jasmine.objectContaining({message: 'before'}), + jasmine.objectContaining({message: 'apply'}), + jasmine.objectContaining({message: 'after'}), + ]); + }); + + it('should maintain order when applying to root path', () => { + const s = schema<{a: {b: string}}>((p) => { + required(p.a.b, {message: 'before'}); + apply(p, (prop) => { + required(prop.a.b, {message: 'apply'}); + }); + required(p.a.b, {message: 'after'}); + }); + + const data = signal({a: {b: ''}}); + const f = form(data, s, {injector: TestBed.inject(Injector)}); + + expect(f.a.b().errors()).toEqual([ + jasmine.objectContaining({message: 'before'}), + jasmine.objectContaining({message: 'apply'}), + jasmine.objectContaining({message: 'after'}), + ]); + }); + }); +});