refactor(forms): Allow returning plain values from validators

This makes the API nicer to use

(cherry picked from commit b5c29d0d0a)
This commit is contained in:
kirjs 2025-09-18 19:30:07 -04:00 committed by Kristiyan Kostadinov
parent 098f6751ef
commit 2c2ea2c0da
16 changed files with 375 additions and 66 deletions

View file

@ -175,7 +175,7 @@ export interface FieldState<TValue, TKey extends string | number = string | numb
export type FieldTree<TValue, TKey extends string | number = string | number> = (() => FieldState<TValue, TKey>) & (TValue extends Array<infer U> ? ReadonlyArrayLike<MaybeFieldTree<U, number>> : TValue extends Record<string, any> ? Subfields<TValue> : unknown);
// @public
export type FieldValidationResult<E extends ValidationError = ValidationError> = ValidationSuccess | OneOrMany<WithoutField<E>>;
export type FieldValidationResult<E extends ValidationError = ValidationError> = ValidationSuccess | OneOrMany<E>;
// @public
export type FieldValidator<TValue, TPathKind extends PathKind = PathKind.Root> = LogicFn<TValue, FieldValidationResult, TPathKind>;
@ -521,7 +521,7 @@ export function validateTree<TValue, TPathKind extends PathKind = PathKind.Root>
// @public
export interface ValidationError {
readonly field: FieldTree<unknown>;
readonly field?: FieldTree<unknown>;
readonly kind: string;
readonly message?: string;
}

View file

@ -18,6 +18,7 @@ import type {
PathKind,
TreeValidator,
} from './types';
import {ensureCustomValidationResult} from './validators/util';
/**
* Adds logic to a field to conditionally disable it. A disabled field does not contribute to the
@ -123,9 +124,11 @@ export function validate<TValue, TPathKind extends PathKind = PathKind.Root>(
assertPathIsCurrent(path);
const pathNode = FieldPathNode.unwrapFieldPath(path);
pathNode.logic.addSyncErrorRule((ctx) =>
addDefaultField(logic(ctx as FieldContext<TValue, TPathKind>), ctx.field),
);
pathNode.logic.addSyncErrorRule((ctx) => {
return ensureCustomValidationResult(
addDefaultField(logic(ctx as FieldContext<TValue, TPathKind>), ctx.field),
);
});
}
/**

View file

@ -26,7 +26,7 @@ import type {
SchemaOrSchemaFn,
TreeValidationResult,
} from './types';
import {ValidationError, WithOptionalField} from './validation_errors';
import {ValidationError, ValidationErrorWithField, WithOptionalField} from './validation_errors';
/**
* Options that may be specified when creating a form.
@ -425,7 +425,7 @@ function setServerErrors(
if (!isArray(errors)) {
errors = [errors];
}
const errorsByField = new Map<FieldNode, ValidationError[]>();
const errorsByField = new Map<FieldNode, ValidationErrorWithField[]>();
for (const error of errors) {
const errorWithField = addDefaultField(error, submittedField.fieldProxy);
const field = errorWithField.field() as FieldNode;

View file

@ -9,7 +9,11 @@
import {Signal, ɵFieldState} from '@angular/core';
import type {Field} from './field_directive';
import {AggregateMetadataKey, MetadataKey} from './metadata';
import type {ValidationError, WithOptionalField, WithoutField} from './validation_errors';
import type {
ValidationError,
ValidationErrorWithField,
WithOptionalField,
} from './validation_errors';
/**
* Symbol used to retain generic type information when it would otherwise be lost.
@ -102,7 +106,7 @@ export type ValidationSuccess = null | undefined | void;
*/
export type FieldValidationResult<E extends ValidationError = ValidationError> =
| ValidationSuccess
| OneOrMany<WithoutField<E>>;
| OneOrMany<E>;
/**
* The result of running a tree validation function.
@ -248,12 +252,12 @@ export interface FieldState<TValue, TKey extends string | number = string | numb
*/
readonly hidden: Signal<boolean>;
readonly disabledReasons: Signal<readonly DisabledReason[]>;
readonly errors: Signal<ValidationError[]>;
readonly errors: Signal<ValidationErrorWithField[]>;
/**
* A signal containing the {@link errors} of the field and its descendants.
*/
readonly errorSummary: Signal<ValidationError[]>;
readonly errorSummary: Signal<ValidationErrorWithField[]>;
/**
* A signal indicating whether the field's value is currently valid.

View file

@ -279,7 +279,7 @@ export function standardSchemaError(
* @category validation
* @experimental 21.0.0
*/
export function customError<E extends Partial<ValidationError>>(
export function customError<E extends Partial<ValidationErrorWithField>>(
obj: WithField<E>,
): CustomValidationError;
/**
@ -289,10 +289,10 @@ export function customError<E extends Partial<ValidationError>>(
* @category validation
* @experimental 21.0.0
*/
export function customError<E extends Partial<ValidationError>>(
export function customError<E extends Partial<ValidationErrorWithField>>(
obj?: E,
): WithoutField<CustomValidationError>;
export function customError<E extends Partial<ValidationError>>(
export function customError<E extends Partial<ValidationErrorWithField>>(
obj?: E,
): WithOptionalField<CustomValidationError> {
return new CustomValidationError(obj);
@ -309,12 +309,15 @@ export function customError<E extends Partial<ValidationError>>(
export interface ValidationError {
/** Identifies the kind of error. */
readonly kind: string;
/** The field associated with this error. */
readonly field: FieldTree<unknown>;
/** Human readable error message. */
readonly message?: string;
}
export interface ValidationErrorWithField extends ValidationError {
/** The field associated with this error. */
readonly field: FieldTree<unknown>;
}
/**
* A custom error that may contain additional properties
*

View file

@ -6,8 +6,9 @@
* found in the LICENSE file at https://angular.dev/license
*/
import {LogicFn, OneOrMany, PathKind, type FieldContext} from '../types';
import {ValidationError, WithoutField} from '../validation_errors';
import {LogicFn, OneOrMany, PathKind, type FieldContext, ValidationResult} from '../types';
import {customError, ValidationError, ValidationErrorWithField} from '../validation_errors';
import {isArray} from '../../util/type_guards';
/** Represents a value that has a length or size, such as an array or string, or set. */
export type ValueWithLengthOrSize = {length: number} | {size: number};
@ -24,9 +25,7 @@ export type BaseValidatorConfig<TValue, TPathKind extends PathKind = PathKind.Ro
* Custom validation error(s) to report instead of the default,
* or a function that receives the `FieldContext` and returns custom validation error(s).
*/
error?:
| OneOrMany<WithoutField<ValidationError>>
| LogicFn<TValue, OneOrMany<WithoutField<ValidationError>>, TPathKind>;
error?: OneOrMany<ValidationError> | LogicFn<TValue, OneOrMany<ValidationError>, TPathKind>;
message?: never;
};
@ -60,3 +59,43 @@ export function isEmpty(value: unknown): boolean {
}
return value === '' || value === false || value == null;
}
/**
* Whether the value is a plain object, as opposed to being an instance of Validation error.
* @param error An error that could be a plain object, or an instance of a class implementing ValidationError.
*/
function isPlainError(error: ValidationError) {
return (
typeof error === 'object' &&
(Object.getPrototypeOf(error) === Object.prototype || Object.getPrototypeOf(error) === null)
);
}
/**
* If the value provided is a plain object, it wraps it into a custom error.
* @param error An error that could be a plain object, or an instance of a class implementing ValidationError.
*/
function ensureCustomValidationError(error: ValidationErrorWithField): ValidationErrorWithField {
if (isPlainError(error)) {
return customError(error);
}
return error;
}
/**
* Makes sure every provided error is wrapped as a custom error.
* @param result Validation result with a field.пше з
*/
export function ensureCustomValidationResult(
result: ValidationResult<ValidationErrorWithField>,
): ValidationResult<ValidationErrorWithField> {
if (result === null || result === undefined) {
return result;
}
if (isArray(result)) {
return result.map(ensureCustomValidationError);
}
return ensureCustomValidationError(result);
}

View file

@ -19,7 +19,7 @@ import {
REQUIRED,
} from '../api/metadata';
import type {DisabledReason, FieldContext, FieldState, FieldTree} from '../api/types';
import type {ValidationError} from '../api/validation_errors';
import type {ValidationError, ValidationErrorWithField} from '../api/validation_errors';
import {LogicNode} from '../schema/logic_node';
import {FieldPathNode} from '../schema/path_node';
import {FieldNodeContext} from './context';
@ -90,11 +90,11 @@ export class FieldNode implements FieldState<unknown> {
return this.structure.keyInParent;
}
get errors(): Signal<ValidationError[]> {
get errors(): Signal<ValidationErrorWithField[]> {
return this.validationState.errors;
}
get errorSummary(): Signal<ValidationError[]> {
get errorSummary(): Signal<ValidationErrorWithField[]> {
return this.validationState.errorSummary;
}

View file

@ -7,7 +7,7 @@
*/
import {computed, linkedSignal, Signal, signal, WritableSignal} from '@angular/core';
import {ValidationError} from '../api/validation_errors';
import {ValidationError, type ValidationErrorWithField} from '../api/validation_errors';
import type {FieldNode} from './node';
/**
@ -21,12 +21,12 @@ export class FieldSubmitState {
readonly selfSubmitting = signal<boolean>(false);
/** Server errors that are associated with this field. */
readonly serverErrors: WritableSignal<readonly ValidationError[]>;
readonly serverErrors: WritableSignal<readonly ValidationErrorWithField[]>;
constructor(private readonly node: FieldNode) {
this.serverErrors = linkedSignal({
source: this.node.structure.value,
computation: () => [] as readonly ValidationError[],
computation: () => [] as readonly ValidationErrorWithField[],
});
}

View file

@ -8,7 +8,11 @@
import {computed, Signal, ɵWritable} from '@angular/core';
import type {FieldTree, TreeValidationResult, ValidationResult} from '../api/types';
import type {ValidationError, WithOptionalField} from '../api/validation_errors';
import type {
ValidationError,
ValidationErrorWithField,
WithOptionalField,
} from '../api/validation_errors';
import {isArray} from '../util/type_guards';
import type {FieldNode} from './node';
import {reduceChildren, shortCircuitFalse} from './util';
@ -35,7 +39,7 @@ export interface ValidationState {
* The full set of synchronous tree errors visible to this field. This includes ones that are
* targeted at a descendant field rather than at this field.
*/
rawSyncTreeErrors: Signal<ValidationError[]>;
rawSyncTreeErrors: Signal<ValidationErrorWithField[]>;
/**
* The full set of synchronous errors for this field, including synchronous tree errors and server
@ -43,7 +47,7 @@ export interface ValidationState {
* the perspective of the field state they are either there or not, they are never in a pending
* state.
*/
syncErrors: Signal<ValidationError[]>;
syncErrors: Signal<ValidationErrorWithField[]>;
/**
* Whether the field is considered valid according solely to its synchronous validators.
@ -56,24 +60,24 @@ export interface ValidationState {
* targeted at a descendant field rather than at this field, as well as sentinel 'pending' values
* indicating that the validator is still running and an error could still occur.
*/
rawAsyncErrors: Signal<(ValidationError | 'pending')[]>;
rawAsyncErrors: Signal<(ValidationErrorWithField | 'pending')[]>;
/**
* The asynchronous tree errors visible to this field that are specifically targeted at this field
* rather than a descendant. This also includes all 'pending' sentinel values, since those could
* theoretically result in errors for this field.
*/
asyncErrors: Signal<(ValidationError | 'pending')[]>;
asyncErrors: Signal<(ValidationErrorWithField | 'pending')[]>;
/**
* The combined set of all errors that currently apply to this field.
*/
errors: Signal<ValidationError[]>;
errors: Signal<ValidationErrorWithField[]>;
/**
* The combined set of all errors that currently apply to this field and its descendants.
*/
errorSummary: Signal<ValidationError[]>;
errorSummary: Signal<ValidationErrorWithField[]>;
/**
* Whether this field has any asynchronous validators still pending.
@ -151,7 +155,7 @@ export class FieldValidationState implements ValidationState {
* The full set of synchronous tree errors visible to this field. This includes ones that are
* targeted at a descendant field rather than at this field.
*/
readonly rawSyncTreeErrors: Signal<ValidationError[]> = computed(() => {
readonly rawSyncTreeErrors: Signal<ValidationErrorWithField[]> = computed(() => {
if (this.shouldSkipValidation()) {
return [];
}
@ -168,7 +172,7 @@ export class FieldValidationState implements ValidationState {
* the perspective of the field state they are either there or not, they are never in a pending
* state.
*/
readonly syncErrors: Signal<ValidationError[]> = computed(() => {
readonly syncErrors: Signal<ValidationErrorWithField[]> = computed(() => {
// Short-circuit running validators if validation doesn't apply to this field.
if (this.shouldSkipValidation()) {
return [];
@ -203,7 +207,7 @@ export class FieldValidationState implements ValidationState {
* The synchronous tree errors visible to this field that are specifically targeted at this field
* rather than a descendant.
*/
readonly syncTreeErrors: Signal<ValidationError[]> = computed(() =>
readonly syncTreeErrors: Signal<ValidationErrorWithField[]> = computed(() =>
this.rawSyncTreeErrors().filter((err) => err.field === this.node.fieldProxy),
);
@ -212,7 +216,7 @@ export class FieldValidationState implements ValidationState {
* targeted at a descendant field rather than at this field, as well as sentinel 'pending' values
* indicating that the validator is still running and an error could still occur.
*/
readonly rawAsyncErrors: Signal<(ValidationError | 'pending')[]> = computed(() => {
readonly rawAsyncErrors: Signal<(ValidationErrorWithField | 'pending')[]> = computed(() => {
// Short-circuit running validators if validation doesn't apply to this field.
if (this.shouldSkipValidation()) {
return [];
@ -231,7 +235,7 @@ export class FieldValidationState implements ValidationState {
* rather than a descendant. This also includes all 'pending' sentinel values, since those could
* theoretically result in errors for this field.
*/
readonly asyncErrors: Signal<(ValidationError | 'pending')[]> = computed(() => {
readonly asyncErrors: Signal<(ValidationErrorWithField | 'pending')[]> = computed(() => {
if (this.shouldSkipValidation()) {
return [];
}
@ -339,42 +343,42 @@ export class FieldValidationState implements ValidationState {
}
/** Normalizes a validation result to a list of validation errors. */
function normalizeErrors(error: ValidationResult): readonly ValidationError[] {
function normalizeErrors<T extends ValidationResult>(error: T | readonly T[]): readonly T[] {
if (error === undefined) {
return [];
}
if (isArray(error)) {
return error;
return error as readonly T[];
}
return [error as ValidationError];
return [error as T];
}
/**
* Sets the given field on the given error(s) if it does not already have a field.
* @param errors The error(s) to add the field to
* @param error The error(s) to add the field to
* @param field The default field to add
* @returns The passed in error(s), with its field set.
*/
export function addDefaultField<E extends ValidationError>(
error: WithOptionalField<E>,
field: FieldTree<unknown>,
): E;
): E & {field: FieldTree<unknown>};
export function addDefaultField<E extends ValidationError>(
errors: TreeValidationResult<E>,
field: FieldTree<unknown>,
): ValidationResult<E>;
): ValidationResult<E & {field: FieldTree<unknown>}>;
export function addDefaultField<E extends ValidationError>(
errors: TreeValidationResult<E>,
field: FieldTree<unknown>,
): ValidationResult<E> {
): ValidationResult<E & {field: FieldTree<unknown>}> {
if (isArray(errors)) {
for (const error of errors) {
(error as ɵWritable<ValidationError>).field ??= field;
(error as ɵWritable<ValidationErrorWithField>).field ??= field;
}
} else if (errors) {
(errors as ɵWritable<ValidationError>).field ??= field;
(errors as ɵWritable<ValidationErrorWithField>).field ??= field;
}
return errors as ValidationResult<E>;
return errors as ValidationResult<E & {field: FieldTree<unknown>}>;
}

View file

@ -9,7 +9,7 @@
import {untracked} from '@angular/core';
import {AggregateMetadataKey, MetadataKey} from '../api/metadata';
import {DisabledReason, type FieldContext, type FieldPath, type LogicFn} from '../api/types';
import type {ValidationError} from '../api/validation_errors';
import type {ValidationErrorWithField} from '../api/validation_errors';
import type {FieldNode} from '../field/node';
import {isArray} from '../util/type_guards';
@ -253,11 +253,11 @@ export class LogicContainer {
/** Logic that determines if the field is read-only. */
readonly readonly: BooleanOrLogic;
/** Logic that produces synchronous validation errors for the field. */
readonly syncErrors: ArrayMergeIgnoreLogic<ValidationError, null>;
readonly syncErrors: ArrayMergeIgnoreLogic<ValidationErrorWithField, null>;
/** Logic that produces synchronous validation errors for the field's subtree. */
readonly syncTreeErrors: ArrayMergeIgnoreLogic<ValidationError, null>;
readonly syncTreeErrors: ArrayMergeIgnoreLogic<ValidationErrorWithField, null>;
/** Logic that produces asynchronous validation results (errors or 'pending'). */
readonly asyncErrors: ArrayMergeIgnoreLogic<ValidationError | 'pending', null>;
readonly asyncErrors: ArrayMergeIgnoreLogic<ValidationErrorWithField | 'pending', null>;
/** A map of aggregate metadata keys to the `AbstractLogic` instances that compute their values. */
private readonly aggregateMetadataKeys = new Map<
AggregateMetadataKey<unknown, unknown>,
@ -278,9 +278,11 @@ export class LogicContainer {
this.hidden = new BooleanOrLogic(predicates);
this.disabledReasons = new ArrayMergeLogic(predicates);
this.readonly = new BooleanOrLogic(predicates);
this.syncErrors = ArrayMergeIgnoreLogic.ignoreNull<ValidationError>(predicates);
this.syncTreeErrors = ArrayMergeIgnoreLogic.ignoreNull<ValidationError>(predicates);
this.asyncErrors = ArrayMergeIgnoreLogic.ignoreNull<ValidationError | 'pending'>(predicates);
this.syncErrors = ArrayMergeIgnoreLogic.ignoreNull<ValidationErrorWithField>(predicates);
this.syncTreeErrors = ArrayMergeIgnoreLogic.ignoreNull<ValidationErrorWithField>(predicates);
this.asyncErrors = ArrayMergeIgnoreLogic.ignoreNull<ValidationErrorWithField | 'pending'>(
predicates,
);
}
/** Checks whether there is logic for the given aggregate metadata key. */

View file

@ -16,6 +16,7 @@ import type {
} from '../api/types';
import {setBoundPathDepthForResolution} from '../field/resolution';
import {BoundPredicate, LogicContainer, Predicate} from './logic';
import {ValidationErrorWithField} from '../api/validation_errors';
/**
* Abstract base class for building a `LogicNode`.
@ -31,21 +32,28 @@ export abstract class AbstractLogicNodeBuilder {
/** Adds a rule to determine if a field should be hidden. */
abstract addHiddenRule(logic: LogicFn<any, boolean>): void;
/** Adds a rule to determine if a field should be disabled, and for what reason. */
abstract addDisabledReasonRule(logic: LogicFn<any, DisabledReason | undefined>): void;
/** Adds a rule to determine if a field should be read-only. */
abstract addReadonlyRule(logic: LogicFn<any, boolean>): void;
/** Adds a rule for synchronous validation errors for a field. */
abstract addSyncErrorRule(logic: LogicFn<any, ValidationResult>): void;
/** Adds a rule for synchronous validation errors that apply to a subtree. */
abstract addSyncTreeErrorRule(logic: LogicFn<any, ValidationResult>): void;
/** Adds a rule for asynchronous validation errors for a field. */
abstract addAsyncErrorRule(logic: LogicFn<any, AsyncValidationResult>): void;
/** Adds a rule to compute aggregate metadata for a field. */
abstract addAggregateMetadataRule<M>(
key: AggregateMetadataKey<unknown, M>,
logic: LogicFn<any, M>,
): void;
/** Adds a factory function to produce a data value associated with a field. */
abstract addMetadataFactory<D>(key: MetadataKey<D>, factory: (ctx: FieldContext<any>) => D): void;
/**
@ -105,15 +113,19 @@ export class LogicNodeBuilder extends AbstractLogicNodeBuilder {
this.getCurrent().addReadonlyRule(logic);
}
override addSyncErrorRule(logic: LogicFn<any, ValidationResult>): void {
override addSyncErrorRule(logic: LogicFn<any, ValidationResult<ValidationErrorWithField>>): void {
this.getCurrent().addSyncErrorRule(logic);
}
override addSyncTreeErrorRule(logic: LogicFn<any, ValidationResult>): void {
override addSyncTreeErrorRule(
logic: LogicFn<any, ValidationResult<ValidationErrorWithField>>,
): void {
this.getCurrent().addSyncTreeErrorRule(logic);
}
override addAsyncErrorRule(logic: LogicFn<any, AsyncValidationResult>): void {
override addAsyncErrorRule(
logic: LogicFn<any, AsyncValidationResult<ValidationErrorWithField>>,
): void {
this.getCurrent().addAsyncErrorRule(logic);
}
@ -221,15 +233,19 @@ class NonMergeableLogicNodeBuilder extends AbstractLogicNodeBuilder {
this.logic.readonly.push(setBoundPathDepthForResolution(logic, this.depth));
}
override addSyncErrorRule(logic: LogicFn<any, ValidationResult>): void {
override addSyncErrorRule(logic: LogicFn<any, ValidationResult<ValidationErrorWithField>>): void {
this.logic.syncErrors.push(setBoundPathDepthForResolution(logic, this.depth));
}
override addSyncTreeErrorRule(logic: LogicFn<any, ValidationResult>): void {
override addSyncTreeErrorRule(
logic: LogicFn<any, ValidationResult<ValidationErrorWithField>>,
): void {
this.logic.syncTreeErrors.push(setBoundPathDepthForResolution(logic, this.depth));
}
override addAsyncErrorRule(logic: LogicFn<any, AsyncValidationResult>): void {
override addAsyncErrorRule(
logic: LogicFn<any, AsyncValidationResult<ValidationErrorWithField>>,
): void {
this.logic.asyncErrors.push(setBoundPathDepthForResolution(logic, this.depth));
}

View file

@ -8,7 +8,7 @@
import {Injector, signal} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {MIN, form, min} from '../../../../public_api';
import {MIN, form, min, validate} from '../../../../public_api';
import {customError, minError} from '../../../../src/api/validation_errors';
describe('min validator', () => {
@ -52,6 +52,28 @@ describe('min validator', () => {
});
describe('custom errors', () => {
it('returns custom errors when provided', () => {
const cat = signal({name: 'pirojok-the-cat', age: 3});
const f = form(
cat,
(p) => {
validate(p.age, () => {
return {kind: 'meow', customProp: 'pirojok-the-prop'};
});
},
{injector: TestBed.inject(Injector)},
);
expect(f.age().errors()).toEqual([
customError({
kind: 'meow',
customProp: 'pirojok-the-prop',
field: f.age,
}),
]);
});
it('returns custom errors when provided', () => {
const cat = signal({name: 'pirojok-the-cat', age: 3});
const f = form(
@ -75,6 +97,32 @@ describe('min validator', () => {
]);
});
it('wraps custom errors if needed', () => {
const cat = signal({name: 'pirojok-the-cat', age: 3});
const f = form(
cat,
(p) => {
min(p.age, 5, {
error: ({value}) => {
return {
kind: 'special-min',
message: value().toString(),
};
},
});
},
{injector: TestBed.inject(Injector)},
);
expect(f.age().errors()).toEqual([
customError({
kind: 'special-min',
message: '3',
field: f.age,
}),
]);
});
it('supports custom error messages', () => {
const cat = signal({name: 'pirojok-the-cat', age: 3});
const f = form(

View file

@ -87,4 +87,25 @@ describe('required validator', () => {
f.age().value.set(15);
expect(f.name().errors()).toEqual([requiredError({field: f.name})]);
});
it('supports returning custom plain error', () => {
const cat = signal({name: 'meow', age: 5});
const f = form(
cat,
(p) => {
required(p.name, {
error: () => {
return {kind: 'pirojok-the-error'};
},
});
},
{
injector: TestBed.inject(Injector),
},
);
expect(f.name().errors()).toEqual([]);
f.name().value.set('');
expect(f.name().errors()).toEqual([customError({kind: 'pirojok-the-error', field: f.name})]);
});
});

View file

@ -0,0 +1,50 @@
/**
* @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 {ensureCustomValidationResult} from '../../../../src/api/validators/util';
import {
customError,
ValidationError,
ValidationErrorWithField,
} from '../../../../src/api/validation_errors';
import {FieldTree} from '../../../../src/api/types';
describe('validators utils', () => {
describe('makeValidationResultCustomIfNeeded', () => {
it('should return null and undefined as is', () => {
expect(ensureCustomValidationResult(null)).toBe(null);
expect(ensureCustomValidationResult(undefined)).toBe(undefined);
});
it('should wrap a plain error object with customError', () => {
const error: ValidationErrorWithField = {kind: 'meow', field: {} as FieldTree<unknown>};
const result = ensureCustomValidationResult(error);
expect(result).toEqual(customError(error));
});
it('should not wrap an error that is not a plain object', () => {
const custom = customError({kind: 'meow'});
const result = ensureCustomValidationResult(custom);
expect(result).toBe(custom);
});
describe('arrays', () => {
it('should process a mixed array of validation errors', () => {
const plainError: ValidationErrorWithField = {
kind: 'plain',
field: {} as FieldTree<unknown>,
};
const custom = customError({kind: 'custom'});
const result = ensureCustomValidationResult([plainError, custom]);
expect(result).toEqual([customError(plainError), custom]);
});
});
});
});

View file

@ -0,0 +1,119 @@
/**
* @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 {form} from '../../../../src/api/structure';
import {TestBed} from '@angular/core/testing';
import {validate} from '../../../../src/api/logic';
import {
customError,
CustomValidationError,
minError,
MinValidationError,
ValidationError,
} from '../../../../src/api/validation_errors';
describe('validation errors', () => {
it('supports returning a a plain object ', () => {
const cat = signal('meow');
const f = form(
cat,
(p) => {
validate(p, () => {
return {kind: 'i am a custom error'};
});
},
{injector: TestBed.inject(Injector)},
);
expect(f().errors()[0]).toBeInstanceOf(CustomValidationError);
});
it('supports returning a list of errors', () => {
const cat = signal('meow');
const f = form(
cat,
(p) => {
validate(p, () => {
return [
{
kind: 'pirojok-the-error',
},
customError({kind: 'meow'}),
minError(4),
];
});
},
{injector: TestBed.inject(Injector)},
);
expect(f().errors()[0]).toBeInstanceOf(CustomValidationError);
expect(f().errors()[1]).toBeInstanceOf(CustomValidationError);
expect(f().errors()[2]).toBeInstanceOf(MinValidationError);
});
it('supports creating dynamic list of errors with explicit type', () => {
const cat = signal('meow');
const f = form(
cat,
(p) => {
validate(p, () => {
const array: ValidationError[] = [];
array.push({
kind: 'custom',
});
array.push(minError(5));
return array;
});
},
{injector: TestBed.inject(Injector)},
);
expect(f().errors()[0]).toBeInstanceOf(CustomValidationError);
expect(f().errors()[1]).toBeInstanceOf(MinValidationError);
});
it('supports custom errors', () => {
class PirojokError implements ValidationError {
readonly kind = 'pirojok-the-error';
constructor(readonly flavor: string) {}
}
function createPirojokError(flavor: string) {
return new PirojokError(flavor);
}
const cat = signal({password: 'pirojok-the-password'});
const f = form(
cat,
(p) => {
validate(p.password, () => {
return createPirojokError('cherry');
});
},
{injector: TestBed.inject(Injector)},
);
const error = f.password().errors()[0]!;
expect(error).toBeInstanceOf(PirojokError);
if (error instanceof PirojokError) {
expect(error.flavor).toBe('cherry');
} else {
fail('this should not happen');
}
});
});

View file

@ -19,17 +19,17 @@ import {
validateAsync,
validateTree,
ValidationError,
type WithoutField,
ValidationErrorWithField,
} from '../../public_api';
function validateValue(value: string): WithoutField<ValidationError>[] {
function validateValue(value: string): ValidationError[] {
return value === 'INVALID' ? [customError()] : [];
}
function validateValueForChild(
value: string,
field: FieldTree<unknown> | undefined,
): ValidationError[] {
): ValidationErrorWithField[] {
return value === 'INVALID' ? [customError({field})] : [];
}