diff --git a/adev/src/content/guide/forms/signals/validation.md b/adev/src/content/guide/forms/signals/validation.md index 5f3b293eb81..522bd0b746d 100644 --- a/adev/src/content/guide/forms/signals/validation.md +++ b/adev/src/content/guide/forms/signals/validation.md @@ -110,12 +110,22 @@ export class RegistrationComponent { } ``` -A field is considered "empty" when: +A field is considered "empty" when its value is one of the following, and non-empty for every other +value — including `0` and the empty array `[]`: -| Condition | Example | -| ------------------------ | ------- | -| Value is `null` | `null`, | -| Value is an empty string | `''` | +| Condition | Example | +| ------------------------ | ----------- | +| Value is `null` | `null` | +| Value is `undefined` | `undefined` | +| Value is an empty string | `''` | +| Value is `false` | `false` | +| Value is `NaN` | `NaN` | + +The last two are worth calling out: + +- `false` is empty to follow the native semantics of `required` on ``, where + an unchecked box fails validation. +- `NaN` is empty because it is usually the result of a parsing error, and is not a valid number. For conditional requirements, use the `when` option: @@ -130,7 +140,7 @@ registrationForm = form(this.registrationModel, (schemaPath) => { The validation rule only runs when the `when` function returns `true`. -Note: `required` treats an empty array as present (valid), so use [`minLength()`](#minlength-and-maxlength) to enforce a minimum number of array items; it treats `false` as missing (invalid), matching ``. +Note: `required` treats an empty array as present (valid), so use [`minLength()`](#minlength-and-maxlength) to enforce a minimum number of array items. ### email() @@ -505,9 +515,7 @@ interface User { lastName: string; } -@Component({ - /* ... */ -}) +@Component({/* ... */}) export class UserFormComponent { readonly userModel = model({ firstName: '', @@ -750,9 +758,7 @@ import {Component, computed, signal} from '@angular/core'; import {form, FormField, validateStandardSchema} from '@angular/forms/signals'; import z from 'zod'; -@Component({ - /* ... */ -}) +@Component({/* ... */}) export class DynamicSchema { model = signal({document: '', type: 'dni'}); diff --git a/packages/forms/signals/src/api/rules/validation/required.ts b/packages/forms/signals/src/api/rules/validation/required.ts index 69132d5fa39..9478fb86320 100644 --- a/packages/forms/signals/src/api/rules/validation/required.ts +++ b/packages/forms/signals/src/api/rules/validation/required.ts @@ -17,6 +17,14 @@ import {requiredError} from './validation_errors'; * This function can only be called on any type of path. * In addition to binding a validator, this function adds `REQUIRED` property to the field. * + * A value is considered empty when it is `null`, `undefined`, the empty string `''`, `false`, or + * `NaN`. Every other value is considered non-empty, including `0` and the empty array `[]` — use + * [`minLength()`](api/forms/signals/minLength) to require a minimum number of items in an array. + * + * `false` is empty to follow the native semantics of `required` on ``, where + * an unchecked box fails validation. `NaN` is empty because it is usually the result of a parsing + * error, and is not a valid number. + * * @param path Path of the field to validate * @param config Optional, allows providing any of the following options: * - `message`: A user-facing message for the error. 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 fe7b5f2b6f0..587782c99c4 100644 --- a/packages/forms/signals/test/node/api/validators/required.spec.ts +++ b/packages/forms/signals/test/node/api/validators/required.spec.ts @@ -12,6 +12,58 @@ import {form, required} from '../../../../public_api'; import {requiredError} from '../../../../src/api/rules/validation/validation_errors'; describe('required validator', () => { + // Documented on `required()` and in guide/forms/signals/validation#required. `false` follows the + // native semantics of `required` on ``; `NaN` is not a valid number. + describe('emptiness', () => { + it('treats null, empty string, false and NaN as empty', () => { + const model = signal<{ + nullable: string | null; + text: string; + checkbox: boolean; + num: number; + }>({nullable: null, text: '', checkbox: false, num: Number.NaN}); + const f = form( + model, + (p) => { + required(p.nullable); + required(p.text); + required(p.checkbox); + required(p.num); + }, + {injector: TestBed.inject(Injector)}, + ); + + expect(f.nullable().errors()).toEqual([requiredError({fieldTree: f.nullable})]); + expect(f.text().errors()).toEqual([requiredError({fieldTree: f.text})]); + expect(f.checkbox().errors()).toEqual([requiredError({fieldTree: f.checkbox})]); + expect(f.num().errors()).toEqual([requiredError({fieldTree: f.num})]); + }); + + it('treats 0, an empty array and other filled values as non-empty', () => { + const model = signal<{ + zero: number; + list: string[]; + checkbox: boolean; + text: string; + }>({zero: 0, list: [], checkbox: true, text: 'a'}); + const f = form( + model, + (p) => { + required(p.zero); + required(p.list); + required(p.checkbox); + required(p.text); + }, + {injector: TestBed.inject(Injector)}, + ); + + expect(f.zero().errors()).toEqual([]); + expect(f.list().errors()).toEqual([]); + expect(f.checkbox().errors()).toEqual([]); + expect(f.text().errors()).toEqual([]); + }); + }); + it('returns required Error when the value is not present', () => { const cat = signal({name: ''}); const f = form(