diff --git a/goldens/public-api/forms/signals/index.api.md b/goldens/public-api/forms/signals/index.api.md index 62dc352d4ad..875a25999a9 100644 --- a/goldens/public-api/forms/signals/index.api.md +++ b/goldens/public-api/forms/signals/index.api.md @@ -175,7 +175,7 @@ export interface FieldState = (() => FieldState) & (TValue extends Array ? ReadonlyArrayLike> : TValue extends Record ? Subfields : unknown); // @public -export type FieldValidationResult = ValidationSuccess | OneOrMany>; +export type FieldValidationResult = ValidationSuccess | OneOrMany; // @public export type FieldValidator = LogicFn; @@ -521,7 +521,7 @@ export function validateTree // @public export interface ValidationError { - readonly field: FieldTree; + readonly field?: FieldTree; readonly kind: string; readonly message?: string; } diff --git a/packages/forms/signals/src/api/logic.ts b/packages/forms/signals/src/api/logic.ts index f94405a16ae..682500b9b99 100644 --- a/packages/forms/signals/src/api/logic.ts +++ b/packages/forms/signals/src/api/logic.ts @@ -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( assertPathIsCurrent(path); const pathNode = FieldPathNode.unwrapFieldPath(path); - pathNode.logic.addSyncErrorRule((ctx) => - addDefaultField(logic(ctx as FieldContext), ctx.field), - ); + pathNode.logic.addSyncErrorRule((ctx) => { + return ensureCustomValidationResult( + addDefaultField(logic(ctx as FieldContext), ctx.field), + ); + }); } /** diff --git a/packages/forms/signals/src/api/structure.ts b/packages/forms/signals/src/api/structure.ts index e543f2cfa3f..4b732f03445 100644 --- a/packages/forms/signals/src/api/structure.ts +++ b/packages/forms/signals/src/api/structure.ts @@ -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(); + const errorsByField = new Map(); for (const error of errors) { const errorWithField = addDefaultField(error, submittedField.fieldProxy); const field = errorWithField.field() as FieldNode; diff --git a/packages/forms/signals/src/api/types.ts b/packages/forms/signals/src/api/types.ts index 503728d8ca9..a6f6c3e346e 100644 --- a/packages/forms/signals/src/api/types.ts +++ b/packages/forms/signals/src/api/types.ts @@ -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 = | ValidationSuccess - | OneOrMany>; + | OneOrMany; /** * The result of running a tree validation function. @@ -248,12 +252,12 @@ export interface FieldState; readonly disabledReasons: Signal; - readonly errors: Signal; + readonly errors: Signal; /** * A signal containing the {@link errors} of the field and its descendants. */ - readonly errorSummary: Signal; + readonly errorSummary: Signal; /** * A signal indicating whether the field's value is currently valid. diff --git a/packages/forms/signals/src/api/validation_errors.ts b/packages/forms/signals/src/api/validation_errors.ts index 04c10e90068..85c7ade6e93 100644 --- a/packages/forms/signals/src/api/validation_errors.ts +++ b/packages/forms/signals/src/api/validation_errors.ts @@ -279,7 +279,7 @@ export function standardSchemaError( * @category validation * @experimental 21.0.0 */ -export function customError>( +export function customError>( obj: WithField, ): CustomValidationError; /** @@ -289,10 +289,10 @@ export function customError>( * @category validation * @experimental 21.0.0 */ -export function customError>( +export function customError>( obj?: E, ): WithoutField; -export function customError>( +export function customError>( obj?: E, ): WithOptionalField { return new CustomValidationError(obj); @@ -309,12 +309,15 @@ export function customError>( export interface ValidationError { /** Identifies the kind of error. */ readonly kind: string; - /** The field associated with this error. */ - readonly field: FieldTree; /** Human readable error message. */ readonly message?: string; } +export interface ValidationErrorWithField extends ValidationError { + /** The field associated with this error. */ + readonly field: FieldTree; +} + /** * A custom error that may contain additional properties * diff --git a/packages/forms/signals/src/api/validators/util.ts b/packages/forms/signals/src/api/validators/util.ts index 1676cb3a5cb..5e072417e63 100644 --- a/packages/forms/signals/src/api/validators/util.ts +++ b/packages/forms/signals/src/api/validators/util.ts @@ -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> - | LogicFn>, TPathKind>; + error?: OneOrMany | LogicFn, 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, +): ValidationResult { + if (result === null || result === undefined) { + return result; + } + + if (isArray(result)) { + return result.map(ensureCustomValidationError); + } + + return ensureCustomValidationError(result); +} diff --git a/packages/forms/signals/src/field/node.ts b/packages/forms/signals/src/field/node.ts index 9a013592fd2..e9fa8481ed3 100644 --- a/packages/forms/signals/src/field/node.ts +++ b/packages/forms/signals/src/field/node.ts @@ -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 { return this.structure.keyInParent; } - get errors(): Signal { + get errors(): Signal { return this.validationState.errors; } - get errorSummary(): Signal { + get errorSummary(): Signal { return this.validationState.errorSummary; } diff --git a/packages/forms/signals/src/field/submit.ts b/packages/forms/signals/src/field/submit.ts index 76809fab39b..86d9f0087c3 100644 --- a/packages/forms/signals/src/field/submit.ts +++ b/packages/forms/signals/src/field/submit.ts @@ -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(false); /** Server errors that are associated with this field. */ - readonly serverErrors: WritableSignal; + readonly serverErrors: WritableSignal; constructor(private readonly node: FieldNode) { this.serverErrors = linkedSignal({ source: this.node.structure.value, - computation: () => [] as readonly ValidationError[], + computation: () => [] as readonly ValidationErrorWithField[], }); } diff --git a/packages/forms/signals/src/field/validation.ts b/packages/forms/signals/src/field/validation.ts index 6bc65ff8599..2a595c644f7 100644 --- a/packages/forms/signals/src/field/validation.ts +++ b/packages/forms/signals/src/field/validation.ts @@ -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; + rawSyncTreeErrors: Signal; /** * 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; + syncErrors: Signal; /** * 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; + errors: Signal; /** * The combined set of all errors that currently apply to this field and its descendants. */ - errorSummary: Signal; + errorSummary: Signal; /** * 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 = computed(() => { + readonly rawSyncTreeErrors: Signal = 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 = computed(() => { + readonly syncErrors: Signal = 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 = computed(() => + readonly syncTreeErrors: Signal = 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(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( error: WithOptionalField, field: FieldTree, -): E; +): E & {field: FieldTree}; export function addDefaultField( errors: TreeValidationResult, field: FieldTree, -): ValidationResult; +): ValidationResult}>; export function addDefaultField( errors: TreeValidationResult, field: FieldTree, -): ValidationResult { +): ValidationResult}> { if (isArray(errors)) { for (const error of errors) { - (error as ɵWritable).field ??= field; + (error as ɵWritable).field ??= field; } } else if (errors) { - (errors as ɵWritable).field ??= field; + (errors as ɵWritable).field ??= field; } - return errors as ValidationResult; + return errors as ValidationResult}>; } diff --git a/packages/forms/signals/src/schema/logic.ts b/packages/forms/signals/src/schema/logic.ts index 975479d5521..31092f958e6 100644 --- a/packages/forms/signals/src/schema/logic.ts +++ b/packages/forms/signals/src/schema/logic.ts @@ -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; + readonly syncErrors: ArrayMergeIgnoreLogic; /** Logic that produces synchronous validation errors for the field's subtree. */ - readonly syncTreeErrors: ArrayMergeIgnoreLogic; + readonly syncTreeErrors: ArrayMergeIgnoreLogic; /** Logic that produces asynchronous validation results (errors or 'pending'). */ - readonly asyncErrors: ArrayMergeIgnoreLogic; + readonly asyncErrors: ArrayMergeIgnoreLogic; /** A map of aggregate metadata keys to the `AbstractLogic` instances that compute their values. */ private readonly aggregateMetadataKeys = new Map< AggregateMetadataKey, @@ -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(predicates); - this.syncTreeErrors = ArrayMergeIgnoreLogic.ignoreNull(predicates); - this.asyncErrors = ArrayMergeIgnoreLogic.ignoreNull(predicates); + this.syncErrors = ArrayMergeIgnoreLogic.ignoreNull(predicates); + this.syncTreeErrors = ArrayMergeIgnoreLogic.ignoreNull(predicates); + this.asyncErrors = ArrayMergeIgnoreLogic.ignoreNull( + predicates, + ); } /** Checks whether there is logic for the given aggregate metadata key. */ diff --git a/packages/forms/signals/src/schema/logic_node.ts b/packages/forms/signals/src/schema/logic_node.ts index 0158451bda6..84d85169be0 100644 --- a/packages/forms/signals/src/schema/logic_node.ts +++ b/packages/forms/signals/src/schema/logic_node.ts @@ -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): void; + /** Adds a rule to determine if a field should be disabled, and for what reason. */ abstract addDisabledReasonRule(logic: LogicFn): void; + /** Adds a rule to determine if a field should be read-only. */ abstract addReadonlyRule(logic: LogicFn): void; + /** Adds a rule for synchronous validation errors for a field. */ abstract addSyncErrorRule(logic: LogicFn): void; + /** Adds a rule for synchronous validation errors that apply to a subtree. */ abstract addSyncTreeErrorRule(logic: LogicFn): void; + /** Adds a rule for asynchronous validation errors for a field. */ abstract addAsyncErrorRule(logic: LogicFn): void; + /** Adds a rule to compute aggregate metadata for a field. */ abstract addAggregateMetadataRule( key: AggregateMetadataKey, logic: LogicFn, ): void; + /** Adds a factory function to produce a data value associated with a field. */ abstract addMetadataFactory(key: MetadataKey, factory: (ctx: FieldContext) => D): void; /** @@ -105,15 +113,19 @@ export class LogicNodeBuilder extends AbstractLogicNodeBuilder { this.getCurrent().addReadonlyRule(logic); } - override addSyncErrorRule(logic: LogicFn): void { + override addSyncErrorRule(logic: LogicFn>): void { this.getCurrent().addSyncErrorRule(logic); } - override addSyncTreeErrorRule(logic: LogicFn): void { + override addSyncTreeErrorRule( + logic: LogicFn>, + ): void { this.getCurrent().addSyncTreeErrorRule(logic); } - override addAsyncErrorRule(logic: LogicFn): void { + override addAsyncErrorRule( + logic: LogicFn>, + ): 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): void { + override addSyncErrorRule(logic: LogicFn>): void { this.logic.syncErrors.push(setBoundPathDepthForResolution(logic, this.depth)); } - override addSyncTreeErrorRule(logic: LogicFn): void { + override addSyncTreeErrorRule( + logic: LogicFn>, + ): void { this.logic.syncTreeErrors.push(setBoundPathDepthForResolution(logic, this.depth)); } - override addAsyncErrorRule(logic: LogicFn): void { + override addAsyncErrorRule( + logic: LogicFn>, + ): void { this.logic.asyncErrors.push(setBoundPathDepthForResolution(logic, this.depth)); } diff --git a/packages/forms/signals/test/node/api/validators/min.spec.ts b/packages/forms/signals/test/node/api/validators/min.spec.ts index 3702c1efad8..1dac729bc1b 100644 --- a/packages/forms/signals/test/node/api/validators/min.spec.ts +++ b/packages/forms/signals/test/node/api/validators/min.spec.ts @@ -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( diff --git a/packages/forms/signals/test/node/api/validators/required.spec.ts b/packages/forms/signals/test/node/api/validators/required.spec.ts index 6f58506c9ee..8ea7ae086eb 100644 --- a/packages/forms/signals/test/node/api/validators/required.spec.ts +++ b/packages/forms/signals/test/node/api/validators/required.spec.ts @@ -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})]); + }); }); diff --git a/packages/forms/signals/test/node/api/validators/util.spec.ts b/packages/forms/signals/test/node/api/validators/util.spec.ts new file mode 100644 index 00000000000..4ad4fc6b10c --- /dev/null +++ b/packages/forms/signals/test/node/api/validators/util.spec.ts @@ -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}; + 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, + }; + const custom = customError({kind: 'custom'}); + + const result = ensureCustomValidationResult([plainError, custom]); + + expect(result).toEqual([customError(plainError), custom]); + }); + }); + }); +}); diff --git a/packages/forms/signals/test/node/api/validators/validation_errors.spec.ts b/packages/forms/signals/test/node/api/validators/validation_errors.spec.ts new file mode 100644 index 00000000000..a0932968265 --- /dev/null +++ b/packages/forms/signals/test/node/api/validators/validation_errors.spec.ts @@ -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'); + } + }); +}); diff --git a/packages/forms/signals/test/node/validation_status.spec.ts b/packages/forms/signals/test/node/validation_status.spec.ts index c01e0ea5508..0eb793c8fa2 100644 --- a/packages/forms/signals/test/node/validation_status.spec.ts +++ b/packages/forms/signals/test/node/validation_status.spec.ts @@ -19,17 +19,17 @@ import { validateAsync, validateTree, ValidationError, - type WithoutField, + ValidationErrorWithField, } from '../../public_api'; -function validateValue(value: string): WithoutField[] { +function validateValue(value: string): ValidationError[] { return value === 'INVALID' ? [customError()] : []; } function validateValueForChild( value: string, field: FieldTree | undefined, -): ValidationError[] { +): ValidationErrorWithField[] { return value === 'INVALID' ? [customError({field})] : []; }