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
43 changes: 27 additions & 16 deletions projects/kit/forms/src/kit-ionic-form-field.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ import { provideKitIonicSignalForms } from './provide-kit-ionic-signal-forms';
<ion-checkbox [formField]="fields.checkbox"></ion-checkbox>
<ion-radio-group [formField]="fields.radio"></ion-radio-group>
<ion-toggle [formField]="fields.toggle"></ion-toggle>
<ion-input [formField]="fields.explicit" errorText="Cross-field error"></ion-input>
<ion-input [formField]="fields.explicit" [errorText]="boundError"></ion-input>
<ion-input [formField]="fields.custom"></ion-input>
<ion-input [formField]="fields.unknown"></ion-input>
<ion-input data-testid="static-error" [formField]="fields.explicit" errorText="Cross-field error"></ion-input>
<ion-input data-testid="bound-error" [formField]="fields.explicit" [errorText]="boundError"></ion-input>
`,
})
class Host {
Expand All @@ -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' });
});
}
Expand All @@ -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);
Expand Down
9 changes: 8 additions & 1 deletion projects/kit/forms/src/kit-ionic-form-field.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -16,12 +17,18 @@ export class KitIonicFormField {
readonly #field = inject(FORM_FIELD, { self: true });
readonly #element = inject(ElementRef<HTMLElement>);
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);
});
Expand Down
30 changes: 30 additions & 0 deletions projects/kit/forms/src/kit-signal-form-error-message.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
49 changes: 49 additions & 0 deletions projects/kit/forms/src/kit-signal-form-error-message.ts
Original file line number Diff line number Diff line change
@@ -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<KitSignalFormErrorMessageResolver>(
'KIT_SIGNAL_FORM_ERROR_MESSAGE_RESOLVER',
{
providedIn: 'root',
factory: () => kitDefaultSignalFormErrorMessage,
},
);
1 change: 1 addition & 0 deletions projects/kit/forms/src/public-api.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './kit-ionic-form-field';
export * from './kit-signal-form-error-message';
export * from './provide-kit-ionic-signal-forms';