refactor(forms): allow passing number|string|null paths to min & max (#65212)

Relaxes the constraints on which paths can be used with the `min` &
`max` validation rules, since people may want to validate a
potentially-null number, or a numeric value represented as a string

PR Close #65212
This commit is contained in:
Miles Malerba 2025-11-14 12:31:10 -08:00 committed by Jessica Janiuk
parent 05cf14ba44
commit ff4633dab8
5 changed files with 74 additions and 18 deletions

View file

@ -274,7 +274,7 @@ export type MapToErrorsFn<TValue, TResult, TPathKind extends PathKind = PathKind
export const MAX: AggregateMetadataKey<number | undefined, number | undefined>;
// @public
export function max<TPathKind extends PathKind = PathKind.Root>(path: SchemaPath<number, SchemaPathRules.Supported, TPathKind>, maxValue: number | LogicFn<number, number | undefined, TPathKind>, config?: BaseValidatorConfig<number, TPathKind>): void;
export function max<TPathKind extends PathKind = PathKind.Root>(path: SchemaPath<number | string | null, SchemaPathRules.Supported, TPathKind>, maxValue: number | LogicFn<number | string | null, number | undefined, TPathKind>, config?: BaseValidatorConfig<number | string | null, TPathKind>): void;
// @public
export const MAX_LENGTH: AggregateMetadataKey<number | undefined, number | undefined>;
@ -335,7 +335,7 @@ export class MetadataKey<TValue> {
export const MIN: AggregateMetadataKey<number | undefined, number | undefined>;
// @public
export function min<TPathKind extends PathKind = PathKind.Root>(path: SchemaPath<number, SchemaPathRules.Supported, TPathKind>, minValue: number | LogicFn<number, number | undefined, TPathKind>, config?: BaseValidatorConfig<number, TPathKind>): void;
export function min<TPathKind extends PathKind = PathKind.Root>(path: SchemaPath<number | string | null, SchemaPathRules.Supported, TPathKind>, minValue: number | LogicFn<number | string | null, number | undefined, TPathKind>, config?: BaseValidatorConfig<number | string | null, TPathKind>): void;
// @public
export const MIN_LENGTH: AggregateMetadataKey<number | undefined, number | undefined>;

View file

@ -9,7 +9,7 @@
import {computed} from '@angular/core';
import {aggregateMetadata, metadata, validate} from '../logic';
import {MAX} from '../metadata';
import {SchemaPath, SchemaPathRules, LogicFn, PathKind} from '../types';
import {LogicFn, PathKind, SchemaPath, SchemaPathRules} from '../types';
import {maxError} from '../validation_errors';
import {BaseValidatorConfig, getOption, isEmpty} from './util';
@ -30,9 +30,9 @@ import {BaseValidatorConfig, getOption, isEmpty} from './util';
* @experimental 21.0.0
*/
export function max<TPathKind extends PathKind = PathKind.Root>(
path: SchemaPath<number, SchemaPathRules.Supported, TPathKind>,
maxValue: number | LogicFn<number, number | undefined, TPathKind>,
config?: BaseValidatorConfig<number, TPathKind>,
path: SchemaPath<number | string | null, SchemaPathRules.Supported, TPathKind>,
maxValue: number | LogicFn<number | string | null, number | undefined, TPathKind>,
config?: BaseValidatorConfig<number | string | null, TPathKind>,
) {
const MAX_MEMO = metadata(path, (ctx) =>
computed(() => (typeof maxValue === 'number' ? maxValue : maxValue(ctx))),
@ -46,7 +46,9 @@ export function max<TPathKind extends PathKind = PathKind.Root>(
if (max === undefined || Number.isNaN(max)) {
return undefined;
}
if (ctx.value() > max) {
const value = ctx.value();
const numValue = !value && value !== 0 ? NaN : Number(value); // Treat `''` and `null` as `NaN`
if (numValue > max) {
if (config?.error) {
return getOption(config.error, ctx);
} else {

View file

@ -9,7 +9,7 @@
import {computed} from '@angular/core';
import {aggregateMetadata, metadata, validate} from '../logic';
import {MIN} from '../metadata';
import {SchemaPath, LogicFn, PathKind, SchemaPathRules} from '../types';
import {LogicFn, PathKind, SchemaPath, SchemaPathRules} from '../types';
import {minError} from '../validation_errors';
import {BaseValidatorConfig, getOption, isEmpty} from './util';
@ -30,9 +30,9 @@ import {BaseValidatorConfig, getOption, isEmpty} from './util';
* @experimental 21.0.0
*/
export function min<TPathKind extends PathKind = PathKind.Root>(
path: SchemaPath<number, SchemaPathRules.Supported, TPathKind>,
minValue: number | LogicFn<number, number | undefined, TPathKind>,
config?: BaseValidatorConfig<number, TPathKind>,
path: SchemaPath<number | string | null, SchemaPathRules.Supported, TPathKind>,
minValue: number | LogicFn<number | string | null, number | undefined, TPathKind>,
config?: BaseValidatorConfig<number | string | null, TPathKind>,
) {
const MIN_MEMO = metadata(path, (ctx) =>
computed(() => (typeof minValue === 'number' ? minValue : minValue(ctx))),
@ -46,7 +46,9 @@ export function min<TPathKind extends PathKind = PathKind.Root>(
if (min === undefined || Number.isNaN(min)) {
return undefined;
}
if (ctx.value() < min) {
const value = ctx.value();
const numValue = !value && value !== 0 ? NaN : Number(value); // Treat `''` and `null` as `NaN`
if (numValue < min) {
if (config?.error) {
return getOption(config.error, ctx);
} else {

View file

@ -59,7 +59,7 @@ describe('max validator', () => {
(p) => {
max(p.age, 5, {
error: ({value}) => {
return customError({kind: 'special-max', message: value().toString()});
return customError({kind: 'special-max', message: value()?.toString()});
},
});
},
@ -104,7 +104,7 @@ describe('max validator', () => {
error: ({value, valueOf}) => {
return valueOf(p.name) === 'disabled'
? []
: customError({kind: 'special-max', message: value().toString()});
: customError({kind: 'special-max', message: value()?.toString()});
},
});
},
@ -155,7 +155,7 @@ describe('max validator', () => {
(p) => {
max(p.age, 5, {
error: ({value}) => {
return customError({kind: 'special-max', message: value().toString()});
return customError({kind: 'special-max', message: value()?.toString()});
},
});
},
@ -299,4 +299,30 @@ describe('max validator', () => {
expect(f.age().errors()).toEqual([]);
});
});
it('should validate properly formatted strings', () => {
const f = form(
signal<number | string | null>('4'),
(p) => {
max(p, -10);
},
{injector: TestBed.inject(Injector)},
);
expect(f().errors()).toEqual([jasmine.objectContaining({kind: 'max'})]);
});
it('should not validate improperly formatted strings or null', () => {
const f = form(
signal<number | string | null>('4f'),
(p) => {
max(p, -10);
},
{injector: TestBed.inject(Injector)},
);
expect(f().errors()).toEqual([]);
f().value.set(null);
expect(f().errors()).toEqual([]);
f().value.set(4);
expect(f().errors()).toEqual([jasmine.objectContaining({kind: 'max'})]);
});
});

View file

@ -59,7 +59,7 @@ describe('min validator', () => {
(p) => {
min(p.age, 5, {
error: ({value}) => {
return customError({kind: 'special-min', message: value().toString()});
return customError({kind: 'special-min', message: value()?.toString()});
},
});
},
@ -84,7 +84,7 @@ describe('min validator', () => {
error: ({value}) => {
return {
kind: 'special-min',
message: value().toString(),
message: value()?.toString(),
};
},
});
@ -130,7 +130,7 @@ describe('min validator', () => {
error: ({value, valueOf}) => {
return valueOf(p.name) === 'disabled'
? []
: customError({kind: 'special-min', message: value().toString()});
: customError({kind: 'special-min', message: value()?.toString()});
},
});
},
@ -308,4 +308,30 @@ describe('min validator', () => {
expect(f.age().errors()).toEqual([]);
});
});
it('should validate properly formatted strings', () => {
const f = form(
signal<number | string | null>('4'),
(p) => {
min(p, 10);
},
{injector: TestBed.inject(Injector)},
);
expect(f().errors()).toEqual([jasmine.objectContaining({kind: 'min'})]);
});
it('should not validate improperly formatted strings or null', () => {
const f = form(
signal<number | string | null>('4f'),
(p) => {
min(p, 10);
},
{injector: TestBed.inject(Injector)},
);
expect(f().errors()).toEqual([]);
f().value.set(null);
expect(f().errors()).toEqual([]);
f().value.set(4);
expect(f().errors()).toEqual([jasmine.objectContaining({kind: 'min'})]);
});
});