refactor(forms): rename field state related to metdata (#64603)

Renames the field state related to metadata to reflect the new
"metadata" name. In particular:
- `property(...)` is renamed to `metadata(...)`
- `hasProperty(...)` is renamed to `hasMetadata(...)`

PR Close #64603
This commit is contained in:
Miles Malerba 2025-10-22 15:04:44 -07:00 committed by Kristiyan Kostadinov
parent f3c547b750
commit a6a5fd7b7d
19 changed files with 94 additions and 98 deletions

View file

@ -162,7 +162,7 @@ export function validateAsync<TValue, TParams, TResult, TPathKind extends PathKi
});
pathNode.logic.addAsyncErrorRule((ctx) => {
const res = ctx.state.property(RESOURCE)!;
const res = ctx.state.metadata(RESOURCE)!;
switch (res.status()) {
case 'idle':
return undefined;

View file

@ -299,21 +299,21 @@ export interface FieldState<TValue, TKey extends string | number = string | numb
readonly fieldBindings: Signal<readonly Field<unknown>[]>;
/**
* Reads an aggregate property value from the field.
* @param prop The property to read.
* Reads an aggregate metadata value from the field.
* @param key The metadata key to read.
*/
property<M>(prop: AggregateMetadataKey<M, any>): Signal<M>;
metadata<M>(key: AggregateMetadataKey<M, any>): Signal<M>;
/**
* Reads a property value from the field.
* @param prop The property key to read.
* Reads a metadata value from the field.
* @param key The metadata key to read.
*/
property<M>(prop: MetadataKey<M>): M | undefined;
metadata<M>(key: MetadataKey<M>): M | undefined;
/**
* Checks whether the given metadata key has been defined for this field.
*/
hasProperty(key: MetadataKey<any> | AggregateMetadataKey<any, any>): boolean;
hasMetadata(key: MetadataKey<any> | AggregateMetadataKey<any, any>): boolean;
/**
* Resets the {@link touched} and {@link dirty} state of the field and its descendants.

View file

@ -37,12 +37,12 @@ export function max<TPathKind extends PathKind = PathKind.Root>(
const MAX_MEMO = metadata(path, (ctx) =>
computed(() => (typeof maxValue === 'number' ? maxValue : maxValue(ctx))),
);
aggregateMetadata(path, MAX, ({state}) => state.property(MAX_MEMO)!());
aggregateMetadata(path, MAX, ({state}) => state.metadata(MAX_MEMO)!());
validate(path, (ctx) => {
if (isEmpty(ctx.value())) {
return undefined;
}
const max = ctx.state.property(MAX_MEMO)!();
const max = ctx.state.metadata(MAX_MEMO)!();
if (max === undefined || Number.isNaN(max)) {
return undefined;
}

View file

@ -47,12 +47,12 @@ export function maxLength<
const MAX_LENGTH_MEMO = metadata(path, (ctx) =>
computed(() => (typeof maxLength === 'number' ? maxLength : maxLength(ctx))),
);
aggregateMetadata(path, MAX_LENGTH, ({state}) => state.property(MAX_LENGTH_MEMO)!());
aggregateMetadata(path, MAX_LENGTH, ({state}) => state.metadata(MAX_LENGTH_MEMO)!());
validate(path, (ctx) => {
if (isEmpty(ctx.value())) {
return undefined;
}
const maxLength = ctx.state.property(MAX_LENGTH_MEMO)!();
const maxLength = ctx.state.metadata(MAX_LENGTH_MEMO)!();
if (maxLength === undefined) {
return undefined;
}

View file

@ -37,12 +37,12 @@ export function min<TPathKind extends PathKind = PathKind.Root>(
const MIN_MEMO = metadata(path, (ctx) =>
computed(() => (typeof minValue === 'number' ? minValue : minValue(ctx))),
);
aggregateMetadata(path, MIN, ({state}) => state.property(MIN_MEMO)!());
aggregateMetadata(path, MIN, ({state}) => state.metadata(MIN_MEMO)!());
validate(path, (ctx) => {
if (isEmpty(ctx.value())) {
return undefined;
}
const min = ctx.state.property(MIN_MEMO)!();
const min = ctx.state.metadata(MIN_MEMO)!();
if (min === undefined || Number.isNaN(min)) {
return undefined;
}

View file

@ -47,12 +47,12 @@ export function minLength<
const MIN_LENGTH_MEMO = metadata(path, (ctx) =>
computed(() => (typeof minLength === 'number' ? minLength : minLength(ctx))),
);
aggregateMetadata(path, MIN_LENGTH, ({state}) => state.property(MIN_LENGTH_MEMO)!());
aggregateMetadata(path, MIN_LENGTH, ({state}) => state.metadata(MIN_LENGTH_MEMO)!());
validate(path, (ctx) => {
if (isEmpty(ctx.value())) {
return undefined;
}
const minLength = ctx.state.property(MIN_LENGTH_MEMO)!();
const minLength = ctx.state.metadata(MIN_LENGTH_MEMO)!();
if (minLength === undefined) {
return undefined;
}

View file

@ -36,12 +36,12 @@ export function pattern<TPathKind extends PathKind = PathKind.Root>(
const PATTERN_MEMO = metadata(path, (ctx) =>
computed(() => (pattern instanceof RegExp ? pattern : pattern(ctx))),
);
aggregateMetadata(path, PATTERN, ({state}) => state.property(PATTERN_MEMO)!());
aggregateMetadata(path, PATTERN, ({state}) => state.metadata(PATTERN_MEMO)!());
validate(path, (ctx) => {
if (isEmpty(ctx.value())) {
return undefined;
}
const pattern = ctx.state.property(PATTERN_MEMO)!();
const pattern = ctx.state.metadata(PATTERN_MEMO)!();
if (pattern === undefined) {
return undefined;
}

View file

@ -39,9 +39,9 @@ export function required<TValue, TPathKind extends PathKind = PathKind.Root>(
const REQUIRED_MEMO = metadata(path, (ctx) =>
computed(() => (config?.when ? config.when(ctx) : true)),
);
aggregateMetadata(path, REQUIRED, ({state}) => state.property(REQUIRED_MEMO)!());
aggregateMetadata(path, REQUIRED, ({state}) => state.metadata(REQUIRED_MEMO)!());
validate(path, (ctx) => {
if (ctx.state.property(REQUIRED_MEMO)!() && isEmpty(ctx.value())) {
if (ctx.state.metadata(REQUIRED_MEMO)!() && isEmpty(ctx.value())) {
if (config?.error) {
return getOption(config.error, ctx);
} else {

View file

@ -69,7 +69,7 @@ export function validateStandardSchema<TSchema, TValue extends IgnoreUnknownProp
});
validateTree(path, ({state, fieldOf}) => {
// Skip sync validation if the result is a Promise.
const result = state.property(VALIDATOR_MEMO)!();
const result = state.metadata(VALIDATOR_MEMO)!();
if (ɵisPromise(result)) {
return [];
}
@ -78,7 +78,7 @@ export function validateStandardSchema<TSchema, TValue extends IgnoreUnknownProp
validateAsync(path, {
params: ({state}) => {
// Skip async validation if the result is *not* a Promise.
const result = state.property(VALIDATOR_MEMO)!();
const result = state.metadata(VALIDATOR_MEMO)!();
return ɵisPromise(result) ? result : undefined;
},
factory: (params) => {

View file

@ -132,7 +132,7 @@ export class InteropNgControl
// This addresses a common case where users look for the presence of `Validators.required` to
// determine whether or not to show a required "*" indicator in the UI.
if (validator === Validators.required) {
return this.field().property(REQUIRED)();
return this.field().metadata(REQUIRED)();
}
return false;
}

View file

@ -25,7 +25,7 @@ import {FieldPathNode} from '../schema/path_node';
import {FieldNodeContext} from './context';
import type {FieldAdapter} from './field_adapter';
import type {FormFieldManager} from './manager';
import {FieldPropertyState} from './property';
import {FieldMetadataState} from './property';
import {FIELD_PROXY_HANDLER} from './proxy';
import {FieldNodeState} from './state';
import {
@ -53,7 +53,7 @@ import {ValidationState} from './validation';
export class FieldNode implements FieldState<unknown> {
readonly structure: FieldNodeStructure;
readonly validationState: ValidationState;
readonly propertyState: FieldPropertyState;
readonly metadataState: FieldMetadataState;
readonly nodeState: FieldNodeState;
readonly submitState: FieldSubmitState;
@ -74,7 +74,7 @@ export class FieldNode implements FieldState<unknown> {
this.structure = this.fieldAdapter.createStructure(this, options);
this.validationState = this.fieldAdapter.createValidationState(this, options);
this.nodeState = this.fieldAdapter.createNodeState(this, options);
this.propertyState = new FieldPropertyState(this);
this.metadataState = new FieldMetadataState(this);
this.submitState = new FieldSubmitState(this);
}
@ -146,41 +146,41 @@ export class FieldNode implements FieldState<unknown> {
return this.nodeState.name;
}
private propertyOrUndefined<M>(prop: AggregateMetadataKey<M, any>): Signal<M> | undefined {
return this.hasProperty(prop) ? this.property(prop) : undefined;
private metadataOrUndefined<M>(key: AggregateMetadataKey<M, any>): Signal<M> | undefined {
return this.hasMetadata(key) ? this.metadata(key) : undefined;
}
get max(): Signal<number | undefined> | undefined {
return this.propertyOrUndefined(MAX);
return this.metadataOrUndefined(MAX);
}
get maxLength(): Signal<number | undefined> | undefined {
return this.propertyOrUndefined(MAX_LENGTH);
return this.metadataOrUndefined(MAX_LENGTH);
}
get min(): Signal<number | undefined> | undefined {
return this.propertyOrUndefined(MIN);
return this.metadataOrUndefined(MIN);
}
get minLength(): Signal<number | undefined> | undefined {
return this.propertyOrUndefined(MIN_LENGTH);
return this.metadataOrUndefined(MIN_LENGTH);
}
get pattern(): Signal<readonly RegExp[]> | undefined {
return this.propertyOrUndefined(PATTERN);
return this.metadataOrUndefined(PATTERN);
}
get required(): Signal<boolean> | undefined {
return this.propertyOrUndefined(REQUIRED);
return this.metadataOrUndefined(REQUIRED);
}
property<M>(prop: AggregateMetadataKey<M, any>): Signal<M>;
property<M>(prop: MetadataKey<M>): M | undefined;
property<M>(prop: MetadataKey<M> | AggregateMetadataKey<M, any>): Signal<M> | M | undefined {
return this.propertyState.get(prop);
metadata<M>(key: AggregateMetadataKey<M, any>): Signal<M>;
metadata<M>(key: MetadataKey<M>): M | undefined;
metadata<M>(key: MetadataKey<M> | AggregateMetadataKey<M, any>): Signal<M> | M | undefined {
return this.metadataState.get(key);
}
hasProperty(prop: MetadataKey<any> | AggregateMetadataKey<any, any>): boolean {
return this.propertyState.has(prop);
hasMetadata(key: MetadataKey<any> | AggregateMetadataKey<any, any>): boolean {
return this.metadataState.has(key);
}
/**

View file

@ -12,63 +12,59 @@ import type {FieldNode} from './node';
import {cast} from './util';
/**
* Tracks custom properties associated with a `FieldNode`.
* Tracks custom metadata associated with a `FieldNode`.
*/
export class FieldPropertyState {
/** A map of all `Property` and `AggregateProperty` that have been defined for this field. */
private readonly properties = new Map<
export class FieldMetadataState {
/** A map of all `MetadataKey` and `AggregateMetadataKey` that have been defined for this field. */
private readonly metadata = new Map<
MetadataKey<unknown> | AggregateMetadataKey<unknown, unknown>,
unknown
>();
constructor(private readonly node: FieldNode) {
// Field nodes (and thus their property state) are created in a linkedSignal in order to mirror
// the structure of the model data. We need to run the property factories untracked so that they
// Field nodes (and thus their metadata state) are created in a linkedSignal in order to mirror
// the structure of the model data. We need to run the metadata factories untracked so that they
// do not cause recomputation of the linkedSignal.
untracked(() =>
// Property factories are run in the form's injection context so they can create resources
// Metadata factories are run in the form's injection context so they can create resources
// and inject DI dependencies.
runInInjectionContext(this.node.structure.injector, () => {
for (const [key, factory] of this.node.logicNode.logic.getMetadataFactoryEntries()) {
this.properties.set(key, factory(this.node.context));
this.metadata.set(key, factory(this.node.context));
}
}),
);
}
/** Gets the value of a `Property` or `AggregateProperty` for the field. */
get<T>(prop: MetadataKey<T> | AggregateMetadataKey<T, unknown>): T | undefined | Signal<T> {
if (prop instanceof MetadataKey) {
return this.properties.get(prop) as T | undefined;
/** Gets the value of a `MetadataKey` or `AggregateMetadataKey` for the field. */
get<T>(key: MetadataKey<T> | AggregateMetadataKey<T, unknown>): T | undefined | Signal<T> {
if (key instanceof MetadataKey) {
return this.metadata.get(key) as T | undefined;
}
// Aggregate properties come with an initial value, and are considered to exist for every field.
// If no logic explicitly contributes values for the property, it is just considered to be the
// initial value. Therefore if the user asks for an aggregate property for a field,
// Aggregate metadata comes with an initial value, and are considered to exist for every field.
// If no logic explicitly contributes values for the metadata, it is just considered to be the
// initial value. Therefore if the user asks for aggregate metadata for a field,
// we just create its computed on the fly.
cast<AggregateMetadataKey<unknown, unknown>>(prop);
if (!this.properties.has(prop)) {
const logic = this.node.logicNode.logic.getAggregateMetadata(prop);
cast<AggregateMetadataKey<unknown, unknown>>(key);
if (!this.metadata.has(key)) {
const logic = this.node.logicNode.logic.getAggregateMetadata(key);
const result = computed(() => logic.compute(this.node.context));
this.properties.set(prop, result);
this.metadata.set(key, result);
}
return this.properties.get(prop)! as Signal<T>;
return this.metadata.get(key)! as Signal<T>;
}
/**
* Checks whether the current property state has the given property.
* @param prop
* @returns
*/
has(prop: MetadataKey<any> | AggregateMetadataKey<any, any>): boolean {
if (prop instanceof AggregateMetadataKey) {
// For aggregate properties, they get added to the map lazily, on first access, so we can't
// rely on checking presence in the properties map. Instead we check if there is any logic for
// the given property.
return this.node.logicNode.logic.hasAggregateMetadata(prop);
/** Checks whether the current metadata state has the given metadata key. */
has(key: MetadataKey<any> | AggregateMetadataKey<any, any>): boolean {
if (key instanceof AggregateMetadataKey) {
// For aggregate metadata keys, they get added to the map lazily, on first access, so we can't
// rely on checking presence in the metadata map. Instead we check if there is any logic for
// the given metadata key.
return this.node.logicNode.logic.hasAggregateMetadata(key);
} else {
// Non-aggregate proeprties get added to our properties map on construction, so we can just
// Non-aggregate metadata gets added to our metadata map on construction, so we can just
// refer to their presence in the map.
return this.properties.has(prop);
return this.metadata.has(key);
}
}
}

View file

@ -162,7 +162,7 @@ describe('max validator', () => {
{injector: TestBed.inject(Injector)},
);
expect(f.age().property(MAX)()).toBe(5);
expect(f.age().metadata(MAX)()).toBe(5);
});
it('merges two maxes preferring the smaller option', () => {
@ -183,7 +183,7 @@ describe('max validator', () => {
f.age().value.set(3);
expect(f.age().errors()).toEqual([]);
expect(f.age().property(MAX)()).toBe(5);
expect(f.age().metadata(MAX)()).toBe(5);
});
it('merges two maxes _dynamically_ preferring the smaller option', () => {
@ -205,12 +205,12 @@ describe('max validator', () => {
f.age().value.set(3);
expect(f.age().errors()).toEqual([]);
expect(f.age().property(MAX)()).toBe(5);
expect(f.age().metadata(MAX)()).toBe(5);
maxSignal.set(2);
f.age().value.set(3);
expect(f.age().errors()).toEqual([maxError(2, {field: f.age})]);
expect(f.age().property(MAX)()).toBe(2);
expect(f.age().metadata(MAX)()).toBe(2);
});
it('merges two maxes _dynamically_ ignores undefined', () => {

View file

@ -156,7 +156,7 @@ describe('maxLength validator', () => {
{injector: TestBed.inject(Injector)},
);
expect(f.text().property(MAX_LENGTH)()).toBe(5);
expect(f.text().metadata(MAX_LENGTH)()).toBe(5);
});
it('merges two maxLengths preferring the smaller option', () => {
@ -182,7 +182,7 @@ describe('maxLength validator', () => {
f.text().value.set('abc');
expect(f.text().errors()).toEqual([]);
expect(f.text().property(MAX_LENGTH)()).toBe(5);
expect(f.text().metadata(MAX_LENGTH)()).toBe(5);
});
it('merges two maxLengths _dynamically_ preferring the smaller option', () => {
@ -212,7 +212,7 @@ describe('maxLength validator', () => {
maxLengthSignal.set(2);
expect(f.text().errors()).toEqual([maxLengthError(2, {field: f.text})]);
expect(f.text().property(MAX_LENGTH)()).toBe(2);
expect(f.text().metadata(MAX_LENGTH)()).toBe(2);
});
});

View file

@ -158,7 +158,7 @@ describe('min validator', () => {
{injector: TestBed.inject(Injector)},
);
expect(f.age().property(MIN)()).toBe(5);
expect(f.age().metadata(MIN)()).toBe(5);
});
it('merges two mins preferring the larger option', () => {
@ -179,7 +179,7 @@ describe('min validator', () => {
f.age().value.set(15);
expect(f.age().errors()).toEqual([]);
expect(f.age().property(MIN)()).toBe(10);
expect(f.age().metadata(MIN)()).toBe(10);
});
it('merges two mins _dynamically_ preferring the larger option', () => {
@ -202,7 +202,7 @@ describe('min validator', () => {
expect(f.age().errors()).toEqual([]);
minSignal.set(30);
expect(f.age().errors()).toEqual([minError(30, {field: f.age})]);
expect(f.age().property(MIN)()).toBe(30);
expect(f.age().metadata(MIN)()).toBe(30);
});
it('merges two mins _dynamically_ ignores undefined', () => {

View file

@ -156,7 +156,7 @@ describe('minLength validator', () => {
{injector: TestBed.inject(Injector)},
);
expect(f.text().property(MIN_LENGTH)()).toBe(5);
expect(f.text().metadata(MIN_LENGTH)()).toBe(5);
});
it('merges two minLengths preferring the larger option', () => {
@ -182,7 +182,7 @@ describe('minLength validator', () => {
f.text().value.set('abcdefghijklmno');
expect(f.text().errors()).toEqual([]);
expect(f.text().property(MIN_LENGTH)()).toBe(10);
expect(f.text().metadata(MIN_LENGTH)()).toBe(10);
});
it('merges two minLengths _dynamically_ preferring the larger option', () => {
@ -212,7 +212,7 @@ describe('minLength validator', () => {
minLengthSignal.set(20);
expect(f.text().errors()).toEqual([minLengthError(20, {field: f.text})]);
expect(f.text().property(MIN_LENGTH)()).toBe(20);
expect(f.text().metadata(MIN_LENGTH)()).toBe(20);
});
});

View file

@ -79,7 +79,7 @@ describe('pattern validator', () => {
{injector: TestBed.inject(Injector)},
);
expect(f.name().property(PATTERN)()).toEqual([/pir.*jok/]);
expect(f.name().metadata(PATTERN)()).toEqual([/pir.*jok/]);
});
it('merges the PATTERN property in an array', () => {
@ -93,7 +93,7 @@ describe('pattern validator', () => {
{injector: TestBed.inject(Injector)},
);
expect(f.name().property(PATTERN)()).toEqual([/pir.*jok/, /pelmeni/]);
expect(f.name().metadata(PATTERN)()).toEqual([/pir.*jok/, /pelmeni/]);
});
it('PATTERN property defaults to empty list', () => {
@ -105,7 +105,7 @@ describe('pattern validator', () => {
},
{injector: TestBed.inject(Injector)},
);
expect(f.name().property(PATTERN)()).toEqual([]);
expect(f.name().metadata(PATTERN)()).toEqual([]);
});
});

View file

@ -802,12 +802,12 @@ describe('FieldNode', () => {
{injector: TestBed.inject(Injector)},
);
expect(f().property(REQUIRED)()).toBe(true);
expect(f().metadata(REQUIRED)()).toBe(true);
expect(f().valid()).toBe(false);
expect(f().readonly()).toBe(false);
isReadonly.set(true);
expect(f().property(REQUIRED)()).toBe(true);
expect(f().metadata(REQUIRED)()).toBe(true);
expect(f().valid()).toBe(true);
expect(f().readonly()).toBe(true);
});
@ -877,13 +877,13 @@ describe('FieldNode', () => {
expect(f.first().errors()).toEqual([requiredError({field: f.first})]);
expect(f.first().valid()).toBe(false);
expect(f.first().property(REQUIRED)()).toBe(true);
expect(f.first().metadata(REQUIRED)()).toBe(true);
f.first().value.set('Bob');
expect(f.first().errors()).toEqual([]);
expect(f.first().valid()).toBe(true);
expect(f.first().property(REQUIRED)()).toBe(true);
expect(f.first().metadata(REQUIRED)()).toBe(true);
});
it('should validate conditionally required field', () => {
@ -899,19 +899,19 @@ describe('FieldNode', () => {
expect(f.first().errors()).toEqual([]);
expect(f.first().valid()).toBe(true);
expect(f.first().property(REQUIRED)()).toBe(false);
expect(f.first().metadata(REQUIRED)()).toBe(false);
f.last().value.set('Loblaw');
expect(f.first().errors()).toEqual([requiredError({field: f.first})]);
expect(f.first().valid()).toBe(false);
expect(f.first().property(REQUIRED)()).toBe(true);
expect(f.first().metadata(REQUIRED)()).toBe(true);
f.first().value.set('Bob');
expect(f.first().errors()).toEqual([]);
expect(f.first().valid()).toBe(true);
expect(f.first().property(REQUIRED)()).toBe(true);
expect(f.first().metadata(REQUIRED)()).toBe(true);
});
it('should link required error messages to their predicate', () => {

View file

@ -64,7 +64,7 @@ describe('resources', () => {
});
validate(p.name, ({state}) => {
const remote = state.property(RES)!;
const remote = state.metadata(RES)!;
if (remote.hasValue()) {
return customError({message: remote.value()});
} else {
@ -106,7 +106,7 @@ describe('resources', () => {
});
validate(p.name, ({state}) => {
const remote = state.property(RES)!;
const remote = state.metadata(RES)!;
if (remote.hasValue()) {
return customError({message: remote.value()});
} else {