From 2fb7cbb24675b487b55cb696dfa79b928322b663 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Wed, 26 Aug 2026 19:47:01 +0900 Subject: [PATCH] feat(forms): provide default validation messages --- .../forms/src/kit-ionic-form-field.spec.ts | 43 ++++++++++------ .../kit/forms/src/kit-ionic-form-field.ts | 9 +++- .../src/kit-signal-form-error-message.spec.ts | 30 ++++++++++++ .../src/kit-signal-form-error-message.ts | 49 +++++++++++++++++++ projects/kit/forms/src/public-api.ts | 1 + 5 files changed, 115 insertions(+), 17 deletions(-) create mode 100644 projects/kit/forms/src/kit-signal-form-error-message.spec.ts create mode 100644 projects/kit/forms/src/kit-signal-form-error-message.ts diff --git a/projects/kit/forms/src/kit-ionic-form-field.spec.ts b/projects/kit/forms/src/kit-ionic-form-field.spec.ts index 5b7d81a..217bc21 100644 --- a/projects/kit/forms/src/kit-ionic-form-field.spec.ts +++ b/projects/kit/forms/src/kit-ionic-form-field.spec.ts @@ -15,8 +15,10 @@ import { provideKitIonicSignalForms } from './provide-kit-ionic-signal-forms'; - - + + + + `, }) class Host { @@ -29,19 +31,19 @@ class Host { checkbox: false, radio: '', toggle: false, + custom: '', + unknown: '', explicit: '', }); readonly fields = form(this.model, (path) => { - validate(path.input, ({ value }) => - value() ? undefined : { kind: 'non-string', message: 123 as unknown as string }, - ); - validate(path.input, ({ value }) => (value() ? undefined : { kind: 'empty', message: ' ' })); - required(path.input, { message: 'Input required' }); - required(path.textarea, { message: 'Textarea required' }); - required(path.select, { message: 'Select required' }); - required(path.checkbox, { message: 'Checkbox required' }); - required(path.radio, { message: 'Radio required' }); - required(path.toggle, { message: 'Toggle required' }); + required(path.input); + required(path.textarea); + required(path.select); + required(path.checkbox); + required(path.radio); + required(path.toggle); + required(path.custom, { message: 'Localized required' }); + validate(path.unknown, ({ value }) => (value() ? undefined : { kind: 'domain-error' })); required(path.explicit, { message: 'Generated error' }); }); } @@ -54,11 +56,20 @@ describe('KitIonicFormField', () => { expect( [...fixture.nativeElement.querySelectorAll('ion-input, ion-textarea, ion-select, ion-checkbox, ion-radio-group, ion-toggle')] - .slice(0, 6) + .slice(0, 8) .map((element: { errorText?: string }) => element.errorText), - ).toEqual(['Input required', 'Textarea required', 'Select required', 'Checkbox required', 'Radio required', 'Toggle required']); - expect(fixture.nativeElement.querySelectorAll('ion-input')[1].errorText).toBe('Cross-field error'); - expect(fixture.nativeElement.querySelectorAll('ion-input')[2].errorText).toBe('Bound cross-field error'); + ).toEqual([ + 'This field is required.', + 'This field is required.', + 'This field is required.', + 'This field is required.', + 'This field is required.', + 'This field is required.', + 'Localized required', + 'Enter a valid value.', + ]); + expect(fixture.nativeElement.querySelector('[data-testid="static-error"]').errorText).toBe('Cross-field error'); + expect(fixture.nativeElement.querySelector('[data-testid="bound-error"]').errorText).toBe('Bound cross-field error'); const input = fixture.nativeElement.querySelector('ion-input'); expect(input.classList.contains('ion-invalid')).toBe(true); diff --git a/projects/kit/forms/src/kit-ionic-form-field.ts b/projects/kit/forms/src/kit-ionic-form-field.ts index 9c9fceb..f7c5295 100644 --- a/projects/kit/forms/src/kit-ionic-form-field.ts +++ b/projects/kit/forms/src/kit-ionic-form-field.ts @@ -1,5 +1,6 @@ import { Directive, effect, ElementRef, inject, Renderer2 } from '@angular/core'; import { FORM_FIELD } from '@angular/forms/signals'; +import { KIT_SIGNAL_FORM_ERROR_MESSAGE_RESOLVER } from './kit-signal-form-error-message'; @Directive({ // Angular's FormField owns [formField]. This sibling directive only adapts its validation message to Ionic. @@ -16,12 +17,18 @@ export class KitIonicFormField { readonly #field = inject(FORM_FIELD, { self: true }); readonly #element = inject(ElementRef); readonly #renderer = inject(Renderer2); + readonly #resolveErrorMessage = inject(KIT_SIGNAL_FORM_ERROR_MESSAGE_RESOLVER); constructor() { effect(() => { const message = this.#field .errors() - .map((error) => error.message) + .map((error) => { + if (typeof error.message === 'string' && error.message.trim().length > 0) { + return error.message; + } + return this.#resolveErrorMessage(error); + }) .find((candidate): candidate is string => typeof candidate === 'string' && candidate.trim().length > 0); this.#renderer.setProperty(this.#element.nativeElement, 'errorText', message); }); diff --git a/projects/kit/forms/src/kit-signal-form-error-message.spec.ts b/projects/kit/forms/src/kit-signal-form-error-message.spec.ts new file mode 100644 index 0000000..2942f8b --- /dev/null +++ b/projects/kit/forms/src/kit-signal-form-error-message.spec.ts @@ -0,0 +1,30 @@ +import { + emailError, + maxDateError, + maxError, + maxLengthError, + minDateError, + minError, + minLengthError, + patternError, + requiredError, +} from '@angular/forms/signals'; +import { kitDefaultSignalFormErrorMessage } from './kit-signal-form-error-message'; + +describe('kitDefaultSignalFormErrorMessage', () => { + it.each([ + [requiredError(), 'This field is required.'], + [emailError(), 'Enter a valid email address.'], + [minError(2), 'Enter a value of at least 2.'], + [maxError(5), 'Enter a value of no more than 5.'], + [minLengthError(3), 'Enter at least 3 characters.'], + [maxLengthError(10), 'Enter no more than 10 characters.'], + [minDateError(new Date('2026-01-02T00:00:00.000Z')), 'Enter a date on or after 2026-01-02.'], + [maxDateError(new Date('2026-12-31T00:00:00.000Z')), 'Enter a date on or before 2026-12-31.'], + [minDateError(new Date(Number.NaN)), 'Enter a date on or after a valid date.'], + [patternError(/\d+/u), 'Enter a value in the required format.'], + [{ kind: 'custom' }, 'Enter a valid value.'], + ])('resolves $kind without a configured message', (error, expected) => { + expect(kitDefaultSignalFormErrorMessage(error)).toBe(expected); + }); +}); diff --git a/projects/kit/forms/src/kit-signal-form-error-message.ts b/projects/kit/forms/src/kit-signal-form-error-message.ts new file mode 100644 index 0000000..d8799bc --- /dev/null +++ b/projects/kit/forms/src/kit-signal-form-error-message.ts @@ -0,0 +1,49 @@ +import { InjectionToken } from '@angular/core'; +import type { + MaxLengthValidationError, + MaxValidationError, + MinLengthValidationError, + MinValidationError, + ValidationError, +} from '@angular/forms/signals'; + +export type KitSignalFormErrorMessageResolver = (error: ValidationError) => string | undefined; + +const formatDate = (value: Date): string => (Number.isNaN(value.getTime()) ? 'a valid date' : value.toISOString().slice(0, 10)); + +/** Resolves Angular Signal Forms built-in validation errors to generic English messages. */ +export const kitDefaultSignalFormErrorMessage: KitSignalFormErrorMessageResolver = (error) => { + switch (error.kind) { + case 'required': + return 'This field is required.'; + case 'email': + return 'Enter a valid email address.'; + case 'min': + return `Enter a value of at least ${(error as MinValidationError).min}.`; + case 'max': + return `Enter a value of no more than ${(error as MaxValidationError).max}.`; + case 'minLength': + return `Enter at least ${(error as MinLengthValidationError).minLength} characters.`; + case 'maxLength': + return `Enter no more than ${(error as MaxLengthValidationError).maxLength} characters.`; + case 'minDate': + return `Enter a date on or after ${formatDate((error as ValidationError & { minDate: Date }).minDate)}.`; + case 'maxDate': + return `Enter a date on or before ${formatDate((error as ValidationError & { maxDate: Date }).maxDate)}.`; + case 'pattern': + return 'Enter a value in the required format.'; + case 'parse': + return 'Enter a valid value.'; + default: + return 'Enter a valid value.'; + } +}; + +/** Override to localize or otherwise customize Signal Forms fallback messages. */ +export const KIT_SIGNAL_FORM_ERROR_MESSAGE_RESOLVER = new InjectionToken( + 'KIT_SIGNAL_FORM_ERROR_MESSAGE_RESOLVER', + { + providedIn: 'root', + factory: () => kitDefaultSignalFormErrorMessage, + }, +); diff --git a/projects/kit/forms/src/public-api.ts b/projects/kit/forms/src/public-api.ts index 6e408be..7f7b561 100644 --- a/projects/kit/forms/src/public-api.ts +++ b/projects/kit/forms/src/public-api.ts @@ -1,2 +1,3 @@ export * from './kit-ionic-form-field'; +export * from './kit-signal-form-error-message'; export * from './provide-kit-ionic-signal-forms';