Skip to content

Commit 170179e

Browse files
authored
feat(forms): provide default validation messages (#87)
1 parent 3b364ee commit 170179e

5 files changed

Lines changed: 115 additions & 17 deletions

File tree

projects/kit/forms/src/kit-ionic-form-field.spec.ts

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@ import { provideKitIonicSignalForms } from './provide-kit-ionic-signal-forms';
1515
<ion-checkbox [formField]="fields.checkbox"></ion-checkbox>
1616
<ion-radio-group [formField]="fields.radio"></ion-radio-group>
1717
<ion-toggle [formField]="fields.toggle"></ion-toggle>
18-
<ion-input [formField]="fields.explicit" errorText="Cross-field error"></ion-input>
19-
<ion-input [formField]="fields.explicit" [errorText]="boundError"></ion-input>
18+
<ion-input [formField]="fields.custom"></ion-input>
19+
<ion-input [formField]="fields.unknown"></ion-input>
20+
<ion-input data-testid="static-error" [formField]="fields.explicit" errorText="Cross-field error"></ion-input>
21+
<ion-input data-testid="bound-error" [formField]="fields.explicit" [errorText]="boundError"></ion-input>
2022
`,
2123
})
2224
class Host {
@@ -29,19 +31,19 @@ class Host {
2931
checkbox: false,
3032
radio: '',
3133
toggle: false,
34+
custom: '',
35+
unknown: '',
3236
explicit: '',
3337
});
3438
readonly fields = form(this.model, (path) => {
35-
validate(path.input, ({ value }) =>
36-
value() ? undefined : { kind: 'non-string', message: 123 as unknown as string },
37-
);
38-
validate(path.input, ({ value }) => (value() ? undefined : { kind: 'empty', message: ' ' }));
39-
required(path.input, { message: 'Input required' });
40-
required(path.textarea, { message: 'Textarea required' });
41-
required(path.select, { message: 'Select required' });
42-
required(path.checkbox, { message: 'Checkbox required' });
43-
required(path.radio, { message: 'Radio required' });
44-
required(path.toggle, { message: 'Toggle required' });
39+
required(path.input);
40+
required(path.textarea);
41+
required(path.select);
42+
required(path.checkbox);
43+
required(path.radio);
44+
required(path.toggle);
45+
required(path.custom, { message: 'Localized required' });
46+
validate(path.unknown, ({ value }) => (value() ? undefined : { kind: 'domain-error' }));
4547
required(path.explicit, { message: 'Generated error' });
4648
});
4749
}
@@ -54,11 +56,20 @@ describe('KitIonicFormField', () => {
5456

5557
expect(
5658
[...fixture.nativeElement.querySelectorAll('ion-input, ion-textarea, ion-select, ion-checkbox, ion-radio-group, ion-toggle')]
57-
.slice(0, 6)
59+
.slice(0, 8)
5860
.map((element: { errorText?: string }) => element.errorText),
59-
).toEqual(['Input required', 'Textarea required', 'Select required', 'Checkbox required', 'Radio required', 'Toggle required']);
60-
expect(fixture.nativeElement.querySelectorAll('ion-input')[1].errorText).toBe('Cross-field error');
61-
expect(fixture.nativeElement.querySelectorAll('ion-input')[2].errorText).toBe('Bound cross-field error');
61+
).toEqual([
62+
'This field is required.',
63+
'This field is required.',
64+
'This field is required.',
65+
'This field is required.',
66+
'This field is required.',
67+
'This field is required.',
68+
'Localized required',
69+
'Enter a valid value.',
70+
]);
71+
expect(fixture.nativeElement.querySelector('[data-testid="static-error"]').errorText).toBe('Cross-field error');
72+
expect(fixture.nativeElement.querySelector('[data-testid="bound-error"]').errorText).toBe('Bound cross-field error');
6273

6374
const input = fixture.nativeElement.querySelector('ion-input');
6475
expect(input.classList.contains('ion-invalid')).toBe(true);

projects/kit/forms/src/kit-ionic-form-field.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Directive, effect, ElementRef, inject, Renderer2 } from '@angular/core';
22
import { FORM_FIELD } from '@angular/forms/signals';
3+
import { KIT_SIGNAL_FORM_ERROR_MESSAGE_RESOLVER } from './kit-signal-form-error-message';
34

45
@Directive({
56
// Angular's FormField owns [formField]. This sibling directive only adapts its validation message to Ionic.
@@ -16,12 +17,18 @@ export class KitIonicFormField {
1617
readonly #field = inject(FORM_FIELD, { self: true });
1718
readonly #element = inject(ElementRef<HTMLElement>);
1819
readonly #renderer = inject(Renderer2);
20+
readonly #resolveErrorMessage = inject(KIT_SIGNAL_FORM_ERROR_MESSAGE_RESOLVER);
1921

2022
constructor() {
2123
effect(() => {
2224
const message = this.#field
2325
.errors()
24-
.map((error) => error.message)
26+
.map((error) => {
27+
if (typeof error.message === 'string' && error.message.trim().length > 0) {
28+
return error.message;
29+
}
30+
return this.#resolveErrorMessage(error);
31+
})
2532
.find((candidate): candidate is string => typeof candidate === 'string' && candidate.trim().length > 0);
2633
this.#renderer.setProperty(this.#element.nativeElement, 'errorText', message);
2734
});
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import {
2+
emailError,
3+
maxDateError,
4+
maxError,
5+
maxLengthError,
6+
minDateError,
7+
minError,
8+
minLengthError,
9+
patternError,
10+
requiredError,
11+
} from '@angular/forms/signals';
12+
import { kitDefaultSignalFormErrorMessage } from './kit-signal-form-error-message';
13+
14+
describe('kitDefaultSignalFormErrorMessage', () => {
15+
it.each([
16+
[requiredError(), 'This field is required.'],
17+
[emailError(), 'Enter a valid email address.'],
18+
[minError(2), 'Enter a value of at least 2.'],
19+
[maxError(5), 'Enter a value of no more than 5.'],
20+
[minLengthError(3), 'Enter at least 3 characters.'],
21+
[maxLengthError(10), 'Enter no more than 10 characters.'],
22+
[minDateError(new Date('2026-01-02T00:00:00.000Z')), 'Enter a date on or after 2026-01-02.'],
23+
[maxDateError(new Date('2026-12-31T00:00:00.000Z')), 'Enter a date on or before 2026-12-31.'],
24+
[minDateError(new Date(Number.NaN)), 'Enter a date on or after a valid date.'],
25+
[patternError(/\d+/u), 'Enter a value in the required format.'],
26+
[{ kind: 'custom' }, 'Enter a valid value.'],
27+
])('resolves $kind without a configured message', (error, expected) => {
28+
expect(kitDefaultSignalFormErrorMessage(error)).toBe(expected);
29+
});
30+
});
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { InjectionToken } from '@angular/core';
2+
import type {
3+
MaxLengthValidationError,
4+
MaxValidationError,
5+
MinLengthValidationError,
6+
MinValidationError,
7+
ValidationError,
8+
} from '@angular/forms/signals';
9+
10+
export type KitSignalFormErrorMessageResolver = (error: ValidationError) => string | undefined;
11+
12+
const formatDate = (value: Date): string => (Number.isNaN(value.getTime()) ? 'a valid date' : value.toISOString().slice(0, 10));
13+
14+
/** Resolves Angular Signal Forms built-in validation errors to generic English messages. */
15+
export const kitDefaultSignalFormErrorMessage: KitSignalFormErrorMessageResolver = (error) => {
16+
switch (error.kind) {
17+
case 'required':
18+
return 'This field is required.';
19+
case 'email':
20+
return 'Enter a valid email address.';
21+
case 'min':
22+
return `Enter a value of at least ${(error as MinValidationError).min}.`;
23+
case 'max':
24+
return `Enter a value of no more than ${(error as MaxValidationError).max}.`;
25+
case 'minLength':
26+
return `Enter at least ${(error as MinLengthValidationError).minLength} characters.`;
27+
case 'maxLength':
28+
return `Enter no more than ${(error as MaxLengthValidationError).maxLength} characters.`;
29+
case 'minDate':
30+
return `Enter a date on or after ${formatDate((error as ValidationError & { minDate: Date }).minDate)}.`;
31+
case 'maxDate':
32+
return `Enter a date on or before ${formatDate((error as ValidationError & { maxDate: Date }).maxDate)}.`;
33+
case 'pattern':
34+
return 'Enter a value in the required format.';
35+
case 'parse':
36+
return 'Enter a valid value.';
37+
default:
38+
return 'Enter a valid value.';
39+
}
40+
};
41+
42+
/** Override to localize or otherwise customize Signal Forms fallback messages. */
43+
export const KIT_SIGNAL_FORM_ERROR_MESSAGE_RESOLVER = new InjectionToken<KitSignalFormErrorMessageResolver>(
44+
'KIT_SIGNAL_FORM_ERROR_MESSAGE_RESOLVER',
45+
{
46+
providedIn: 'root',
47+
factory: () => kitDefaultSignalFormErrorMessage,
48+
},
49+
);
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from './kit-ionic-form-field';
2+
export * from './kit-signal-form-error-message';
23
export * from './provide-kit-ionic-signal-forms';

0 commit comments

Comments
 (0)