mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
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:
parent
098f6751ef
commit
2c2ea2c0da
16 changed files with 375 additions and 66 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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[],
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>}>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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})]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
50
packages/forms/signals/test/node/api/validators/util.spec.ts
Normal file
50
packages/forms/signals/test/node/api/validators/util.spec.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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})] : [];
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue