Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 21 additions & 10 deletions goldens/public-api/forms/signals/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,10 @@ export interface ChildFieldContext<TValue> extends RootFieldContext<TValue> {
export function createMetadataKey<TValue>(): MetadataKey<TValue>;

// @public
export function customError<E extends Partial<ValidationError>>(obj: WithField<E>): CustomValidationError;
export function customError<E extends Partial<ValidationErrorWithField>>(obj: WithField<E>): CustomValidationError;

// @public
export function customError<E extends Partial<ValidationError>>(obj?: E): WithoutField<CustomValidationError>;
export function customError<E extends Partial<ValidationErrorWithField>>(obj?: E): WithoutField<CustomValidationError>;

// @public
export class CustomValidationError implements ValidationError {
Expand Down Expand Up @@ -156,8 +156,8 @@ export interface FieldState<TValue, TKey extends string | number = string | numb
// (undocumented)
readonly disabledReasons: Signal<readonly DisabledReason[]>;
// (undocumented)
readonly errors: Signal<ValidationError[]>;
readonly errorSummary: Signal<ValidationError[]>;
readonly errors: Signal<ValidationErrorWithField[]>;
readonly errorSummary: Signal<ValidationErrorWithField[]>;
readonly fieldBindings: Signal<readonly Field<unknown>[]>;
hasMetadata(key: MetadataKey<any> | AggregateMetadataKey<any, any>): boolean;
readonly hidden: Signal<boolean>;
Expand All @@ -175,10 +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>>;

// @public
export type FieldValidator<TValue, TPathKind extends PathKind = PathKind.Root> = LogicFn<TValue, FieldValidationResult, TPathKind>;
export type FieldValidator<TValue, TPathKind extends PathKind = PathKind.Root> = LogicFn<TValue, ValidationResult<ValidationErrorWithoutField>, TPathKind>;

// @public
export function form<TValue>(model: WritableSignal<TValue>): FieldTree<TValue>;
Expand Down Expand Up @@ -499,7 +496,7 @@ export function submit<TValue>(form: FieldTree<TValue>, action: (form: FieldTree
export type SubmittedStatus = 'unsubmitted' | 'submitted' | 'submitting';

// @public
export type TreeValidationResult<E extends ValidationError = ValidationError> = ValidationSuccess | OneOrMany<WithOptionalField<E>>;
export type TreeValidationResult<E extends ValidationErrorWithOptionalField = ValidationErrorWithOptionalField> = ValidationSuccess | OneOrMany<E>;

// @public
export type TreeValidator<TValue, TPathKind extends PathKind = PathKind.Root> = LogicFn<TValue, TreeValidationResult, TPathKind>;
Expand All @@ -521,11 +518,25 @@ export function validateTree<TValue, TPathKind extends PathKind = PathKind.Root>

// @public
export interface ValidationError {
readonly field: FieldTree<unknown>;
readonly kind: string;
readonly message?: string;
}

// @public
export interface ValidationErrorWithField extends ValidationError {
readonly field: FieldTree<unknown>;
}

// @public
export interface ValidationErrorWithOptionalField extends ValidationError {
readonly field?: FieldTree<unknown>;
}

// @public
export interface ValidationErrorWithoutField extends ValidationError {
readonly field?: never;
}

// @public
export type ValidationResult<E extends ValidationError = ValidationError> = ValidationSuccess | OneOrMany<E>;

Expand Down
9 changes: 6 additions & 3 deletions packages/forms/signals/src/api/logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
);
});
}

/**
Expand Down
6 changes: 3 additions & 3 deletions packages/forms/signals/src/api/structure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import type {
SchemaOrSchemaFn,
TreeValidationResult,
} from './types';
import {ValidationError, WithOptionalField} from './validation_errors';
import {ValidationErrorWithField, type ValidationErrorWithOptionalField} from './validation_errors';

/**
* Options that may be specified when creating a form.
Expand Down Expand Up @@ -420,12 +420,12 @@ export async function submit<TValue>(
*/
function setServerErrors(
submittedField: FieldNode,
errors: OneOrMany<WithOptionalField<ValidationError>>,
errors: OneOrMany<ValidationErrorWithOptionalField>,
) {
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;
Expand Down
37 changes: 12 additions & 25 deletions packages/forms/signals/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@
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,
ValidationErrorWithOptionalField,
ValidationErrorWithoutField,
} from './validation_errors';

/**
* Symbol used to retain generic type information when it would otherwise be lost.
Expand Down Expand Up @@ -86,24 +91,6 @@ export interface DisabledReason {
*/
export type ValidationSuccess = null | undefined | void;

/**
* The result of running a field validation function.
*
* The result may be one of the following:
* 1. A {@link ValidationSuccess} to indicate no errors.
* 2. A {@link ValidationError} without a field to indicate an error on the field being validated.
* 3. A list of {@link ValidationError} without fields to indicate multiple errors on the field
* being validated.
*
* @template E the type of error (defaults to {@link ValidationError}).
*
* @category types
* @experimental 21.0.0
*/
export type FieldValidationResult<E extends ValidationError = ValidationError> =
| ValidationSuccess
| OneOrMany<WithoutField<E>>;

/**
* The result of running a tree validation function.
*
Expand All @@ -118,9 +105,9 @@ export type FieldValidationResult<E extends ValidationError = ValidationError> =
* @category types
* @experimental 21.0.0
*/
export type TreeValidationResult<E extends ValidationError = ValidationError> =
| ValidationSuccess
| OneOrMany<WithOptionalField<E>>;
export type TreeValidationResult<
E extends ValidationErrorWithOptionalField = ValidationErrorWithOptionalField,
> = ValidationSuccess | OneOrMany<E>;

/**
* A validation result where all errors explicitly define their target field.
Expand Down Expand Up @@ -248,12 +235,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.
Expand Down Expand Up @@ -423,7 +410,7 @@ export type LogicFn<TValue, TReturn, TPathKind extends PathKind = PathKind.Root>
*/
export type FieldValidator<TValue, TPathKind extends PathKind = PathKind.Root> = LogicFn<
TValue,
FieldValidationResult,
ValidationResult<ValidationErrorWithoutField>,
TPathKind
>;

Expand Down
45 changes: 39 additions & 6 deletions packages/forms/signals/src/api/validation_errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand All @@ -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);
Expand All @@ -301,20 +301,53 @@ export function customError<E extends Partial<ValidationError>>(
/**
* Common interface for all validation errors.
*
* Use the creation functions to create an instance (e.g. `requiredError`, `minError`, etc.).
* This can be returned from validators.
*
* It's also used by the creation functions to create an instance
* (e.g. `requiredError`, `minError`, etc.).
*
* @category validation
* @experimental 21.0.0
*/
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;
}

/**
* Validation error with a field.
*
* This is returned from field state, e.g., catField.errors() would be of a list of errors with
* `field: catField` bound to state.
*/
export interface ValidationErrorWithField extends ValidationError {
/** The field associated with this error. */
readonly field: FieldTree<unknown>;
}

/**
* Validation error with optional field.
*
* This is generally used in places where the result might have a field.
* e.g., as a result of a `validateTree`, or when handling form submission.
*/
export interface ValidationErrorWithOptionalField extends ValidationError {
/** The field associated with this error. */
readonly field?: FieldTree<unknown>;
}

/**
* Validation error with no field.
*
* This is used to strongly enforce that fields are not allowed in validation result.
*/
export interface ValidationErrorWithoutField extends ValidationError {
/** The field associated with this error. */
readonly field?: never;
}

/**
* A custom error that may contain additional properties
*
Expand Down
49 changes: 44 additions & 5 deletions packages/forms/signals/src/api/validators/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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;
};

Expand Down Expand Up @@ -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);
}
6 changes: 3 additions & 3 deletions packages/forms/signals/src/field/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}

Expand Down
6 changes: 3 additions & 3 deletions packages/forms/signals/src/field/submit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand All @@ -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[],
});
}

Expand Down
Loading