refactor(forms): add better support for Object.keys and Symbol.iterator

Improves support for using `Object.keys` (and related methods) and
`Symbol.iterator` to work with a `FieldTree` of unknown shape.

(cherry picked from commit e87f423d90)
This commit is contained in:
Miles Malerba 2025-10-29 21:53:52 -07:00 committed by Jessica Janiuk
parent d1f4c8c18c
commit f3e2252f4b
4 changed files with 93 additions and 3 deletions

View file

@ -487,6 +487,8 @@ export class StandardSchemaValidationError extends _NgValidationError {
// @public
export type Subfields<TValue> = {
readonly [K in keyof TValue as TValue[K] extends Function ? never : K]: MaybeFieldTree<TValue[K], string>;
} & {
[Symbol.iterator](): Iterator<[string, MaybeFieldTree<TValue[keyof TValue], string>]>;
};
// @public

View file

@ -173,6 +173,8 @@ export type Subfields<TValue> = {
TValue[K],
string
>;
} & {
[Symbol.iterator](): Iterator<[string, MaybeFieldTree<TValue[keyof TValue], string>]>;
};
/**

View file

@ -7,14 +7,14 @@
*/
import {untracked} from '@angular/core';
import {isArray} from '../util/type_guards';
import {isArray, isObject} from '../util/type_guards';
import type {FieldNode} from './node';
/**
* Proxy handler which implements `FieldTree<T>` on top of `FieldNode`.
*/
export const FIELD_PROXY_HANDLER: ProxyHandler<() => FieldNode> = {
get(getTgt: () => FieldNode, p: string | symbol) {
get(getTgt: () => FieldNode, p: string | symbol, receiver: {[key: string]: unknown}) {
const tgt = getTgt();
// First, check whether the requested property is a defined child node of this node.
@ -41,14 +41,40 @@ export const FIELD_PROXY_HANDLER: ProxyHandler<() => FieldNode> = {
// Allow access to the iterator. This allows the user to spread the field array into a
// standard array in order to call methods like `filter`, `map`, etc.
if (p === Symbol.iterator) {
return (Array.prototype as any)[p];
return Array.prototype[p as any];
}
// Note: We can consider supporting additional array methods if we want in the future,
// but they should be thoroughly tested. Just forwarding the method directly from the
// `Array` prototype results in broken behavior for some methods like `map`.
}
if (isObject(value)) {
// For object fields, allow iteration over their entries for convenience of use with `@for`.
if (p === Symbol.iterator) {
return function* () {
for (const key in receiver) {
yield [key, receiver[key]];
}
};
}
}
// Otherwise, this property doesn't exist.
return undefined;
},
getOwnPropertyDescriptor(getTgt, prop) {
const value = untracked(getTgt().value) as Object;
const desc = Reflect.getOwnPropertyDescriptor(value, prop);
// In order for `Object.keys` to function properly, keys must be reported as configurable.
if (desc && !desc.configurable) {
desc.configurable = true;
}
return desc;
},
ownKeys(getTgt: () => FieldNode) {
const value = untracked(getTgt().value);
return typeof value === 'object' && value !== null ? Reflect.ownKeys(value) : [];
},
};

View file

@ -31,4 +31,64 @@ describe('FieldTree proxy', () => {
// @ts-expect-error
f.arr[0] = f.arr[0];
});
it('should get keys and values for object field', () => {
const f = form(signal({x: 1, y: 2}), {injector: TestBed.inject(Injector)});
expect(Object.keys(f)).toEqual(['x', 'y']);
expect(Object.getOwnPropertyNames(f)).toEqual(['x', 'y']);
expect(Object.entries(f).map(([key, child]) => [key, child().value()])).toEqual([
['x', 1],
['y', 2],
]);
expect(Object.values(f).map((child) => child().value())).toEqual([1, 2]);
});
it('should get keys and values for array field', () => {
const f = form(signal([1, 2]), {injector: TestBed.inject(Injector)});
expect(Object.keys(f)).toEqual(['0', '1']);
expect(Object.getOwnPropertyNames(f)).toEqual(['0', '1', 'length']);
expect(Object.entries(f).map(([key, child]) => [key, child().value()])).toEqual([
['0', 1],
['1', 2],
]);
expect(Object.values(f).map((child) => child().value())).toEqual([1, 2]);
});
it('should get keys and values for primitive field', () => {
const f = form(signal(1), {injector: TestBed.inject(Injector)});
expect(Object.keys(f)).toEqual([]);
expect(Object.getOwnPropertyNames(f)).toEqual([]);
expect(Object.entries(f)).toEqual([]);
expect(Object.values(f)).toEqual([]);
});
it('should iterate over object field', () => {
const f = form(signal({x: 1, y: 2}), {injector: TestBed.inject(Injector)});
const result: [string, number][] = [];
for (const [key, child] of f) {
result.push([key, child().value()]);
}
expect(result).toEqual([
['x', 1],
['y', 2],
]);
});
it('should iterate over array field', () => {
const f = form(signal([1, 2]), {injector: TestBed.inject(Injector)});
const result: number[] = [];
for (const child of f) {
result.push(child().value());
}
expect(result).toEqual([1, 2]);
});
it('should not iterate over primitive field', () => {
const f = form(signal(1), {injector: TestBed.inject(Injector)});
expect(() => {
// @ts-expect-error - not iterable
for (const child of f) {
}
}).toThrow();
});
});