diff --git a/README.md b/README.md index cffbec1..f4300c3 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,37 @@ export class AppComponent { } ``` -The directive will show all errors for a form field automatically in two instances - on the field element blur and on form submit. +### Customize the behavior + +By default the directive will show all errors for a form field automatically in two instances: on the field element blur and on form submit. + +The default behavior can be overridden globally or on a per control basis. + +If you want to override the behavior globally, so that it affects all form controls, you can do so via the `controlErrorsOn` property: + +```ts +bootstrapApplication(AppComponent, { + providers: [ + provideErrorTailorConfig({ + errors: { + useValue: { + required: 'This field is required' + } + }, + controlErrorsOn: { + change: true, // errors are shown/hidden on every change + touched: true, // errors are shown/hidden when the controls are marked as touched + } + }) + ] +}) +``` + +Or you can change the behavior of a single form control by adding the relevant inputs. For example, to only show the errors on submit you can set to `false` both `controlErrorsOnBlur` and `controlErrorsOnAsync`: + +```html + +``` ## Inputs @@ -216,6 +246,12 @@ One typical case when to use it is radio buttons in the same radio group where i ``` +- `controlErrorsOnTouched` - To modify the error display behavior to show errors when the control is marked as `touched` via `control.markAsTouched()` or `formGroup.markAllAsTouched()`: + +```html + +``` + ## Methods - `showError()` - Programmatic access to show a control error component (without a blur or a submit event). A validation error should still exist on that element. The key is the published `exportAs` reference of `errorTailor` to a directive instance of `ControlErrorsDirective` and calling its public method `showError()`. @@ -336,17 +372,29 @@ bootstrapApplication(AppComponent, { ``` -- `controlErrorsOn` - Optional. An object that allows the default behavior for showing the errors to be overridden. (each individual property in the object is optional, so it's possible to override only 1 setting) +- `controlErrorsOn` - Optional. Allows to override the default behavior for showing the errors. These are the possible properties and their defaults: -```ts -{ - controlErrorsOn: { - async: true, // (default: true) - blur: true, // (default: true) - change: true, // (default: false) + ```ts + { + controlErrorsOn: { + async: true, + blur: true, + change: false, + touched: false, + } } -} -``` + ``` + + The object will be merged with the default configuration object, so you can simply define one or more properties that you wish to override: + + ```ts + { + controlErrorsOn: { + change: true, + touched: true, + } + } + ``` ## Recipes diff --git a/projects/ngneat/error-tailor/src/lib/control-error.directive.spec.ts b/projects/ngneat/error-tailor/src/lib/control-error.directive.spec.ts index 394636b..87a722a 100644 --- a/projects/ngneat/error-tailor/src/lib/control-error.directive.spec.ts +++ b/projects/ngneat/error-tailor/src/lib/control-error.directive.spec.ts @@ -10,7 +10,12 @@ import { ValidationErrors, Validators, } from '@angular/forms'; -import { ControlErrorsDirective, errorTailorImports, provideErrorTailorConfig } from '@ngneat/error-tailor'; +import { + ControlErrorsDirective, + ErrorTailorConfig, + errorTailorImports, + provideErrorTailorConfig, +} from '@ngneat/error-tailor'; import { byPlaceholder, byText, createComponentFactory, Spectator } from '@ngneat/spectator'; import { map, asyncScheduler, Observable, scheduled } from 'rxjs'; import { DefaultControlErrorComponent } from './control-error.component'; @@ -64,6 +69,7 @@ describe('ControlErrorDirective', () => { + `, @@ -78,6 +84,7 @@ describe('ControlErrorDirective', () => { username: ['', Validators.required, this.usernameValidator.bind(this)], onSubmitOnly: ['', [Validators.required]], onEveryChange: ['', [Validators.required]], + onTouchedChange: ['', [Validators.required]], }); @ViewChild('explicitErrorTailor', { static: true }) explicitErrorTailor: ControlErrorsDirective; @@ -137,8 +144,10 @@ describe('ControlErrorDirective', () => { const onSubmitOnly = spectator.query(byPlaceholder('On submit only')); const onEveryChange = spectator.query(byPlaceholder('On every change')); + const onTouchedChange = spectator.query(byPlaceholder('On touched change')); typeInElementAndFocusOut(spectator, 'test', onSubmitOnly); typeInElementAndFocusOut(spectator, 'test', onEveryChange); + typeInElementAndFocusOut(spectator, 'test', onTouchedChange); spectator.click('button'); @@ -178,6 +187,16 @@ describe('ControlErrorDirective', () => { expect(spectator.query(byText('required error'))).toBeFalsy(); }); + it('should show errors when the control becomes touched when controlErrorsOnTouched is enabled', () => { + const onEveryChange = spectator.query(byPlaceholder('On touched change')); + + expect(spectator.query(byText('required error'))).toBeFalsy(); + + spectator.component.form.markAllAsTouched(); + spectator.detectComponentChanges(); + expect(spectator.query(byText('required error'))).toBeTruthy(); + }); + it('should not show errors on interactions', () => { const ignoredInput = spectator.query(byPlaceholder('Ignored')); @@ -500,10 +519,7 @@ describe('ControlErrorDirective', () => { }) class CustomControlErrorComponent extends DefaultControlErrorComponent {} - function getCustomErrorComponentFactory( - component: Type, - controlErrorComponentAnchorFn: (hostElem: Element, errorElem: Element) => () => void = null, - ) { + function getCustomErrorComponentFactory(component: Type, config: Partial = {}) { return createComponentFactory({ component, providers: [ @@ -516,10 +532,7 @@ describe('ControlErrorDirective', () => { controlErrorsClass: ['global', 'config'], controlCustomClass: 'control custom', controlErrorComponent: CustomControlErrorComponent, - controlErrorComponentAnchorFn, - controlErrorsOn: { - change: true, - }, + ...config, }), ], imports: [FormsModule, CustomControlErrorComponent, ReactiveFormsModule, errorTailorImports], @@ -563,9 +576,8 @@ describe('ControlErrorDirective', () => { let anchorFnDestroyCalled = false; let spectator: Spectator; - const createComponent = getCustomErrorComponentFactory( - CustomErrorFormGroupComponent, - (hostElem: Element, errorElem: Element) => { + const createComponent = getCustomErrorComponentFactory(CustomErrorFormGroupComponent, { + controlErrorComponentAnchorFn: (hostElem: Element, errorElem: Element) => { anchorFnCalled = true; expect(hostElem).toBeTruthy(); expect(errorElem).toBeTruthy(); @@ -573,7 +585,7 @@ describe('ControlErrorDirective', () => { anchorFnDestroyCalled = true; }; }, - ); + }); beforeEach(() => (spectator = createComponent())); @@ -604,24 +616,47 @@ describe('ControlErrorDirective', () => { }); describe('controlErrorsOn', () => { - let spectator: Spectator; - const createComponent = getCustomErrorComponentFactory(CustomErrorFormGroupComponent); + describe('change', () => { + let spectator: Spectator; + const createComponent = getCustomErrorComponentFactory(CustomErrorFormGroupComponent, { + controlErrorsOn: { change: true }, + }); - beforeEach(() => (spectator = createComponent())); + beforeEach(() => (spectator = createComponent())); - it('should override default behavior for showing errors', () => { - const input = spectator.query(byPlaceholder('Name')); + it('should show errors on change when overriding default behavior by setting controlErrorsOn.change to true', () => { + const input = spectator.query(byPlaceholder('Name')); - expect(spectator.query(byText('required error'))).toBeFalsy(); + expect(spectator.query(byText('required error'))).toBeFalsy(); - spectator.typeInElement('test', input); - expect(spectator.query(byText('required error'))).toBeFalsy(); + spectator.typeInElement('test', input); + expect(spectator.query(byText('required error'))).toBeFalsy(); - spectator.typeInElement('', input); - expect(spectator.query(byText('required error'))).toBeTruthy(); + spectator.typeInElement('', input); + expect(spectator.query(byText('required error'))).toBeTruthy(); + + spectator.typeInElement('t', input); + expect(spectator.query(byText('required error'))).toBeFalsy(); + }); + }); + + describe('touched', () => { + let spectator: Spectator; + const createComponent = getCustomErrorComponentFactory(CustomErrorFormGroupComponent, { + controlErrorsOn: { touched: true }, + }); + + beforeEach(() => (spectator = createComponent())); + + it('should show errors when control becomes touched when overriding default behavior by setting controlErrorsOn.touched to true', () => { + const input = spectator.query(byPlaceholder('Name')); + + expect(spectator.query(byText('required error'))).toBeFalsy(); - spectator.typeInElement('t', input); - expect(spectator.query(byText('required error'))).toBeFalsy(); + spectator.component.form.markAllAsTouched(); + spectator.detectComponentChanges(); + expect(spectator.query(byText('required error'))).toBeTruthy(); + }); }); }); }); diff --git a/projects/ngneat/error-tailor/src/lib/control-error.directive.ts b/projects/ngneat/error-tailor/src/lib/control-error.directive.ts index 4b278db..1499811 100644 --- a/projects/ngneat/error-tailor/src/lib/control-error.directive.ts +++ b/projects/ngneat/error-tailor/src/lib/control-error.directive.ts @@ -1,6 +1,7 @@ import { ComponentRef, Directive, + DoCheck, ElementRef, EmbeddedViewRef, Inject, @@ -27,6 +28,7 @@ import { Observable, Subject, tap, + skip, } from 'rxjs'; import { ControlErrorAnchorDirective } from './control-error-anchor.directive'; @@ -43,7 +45,7 @@ const errorTailorClass = 'error-tailor-has-error'; '[formControlName]:not([controlErrorsIgnore]), [formControl]:not([controlErrorsIgnore]), [formGroup]:not([controlErrorsIgnore]), [formGroupName]:not([controlErrorsIgnore]), [formArrayName]:not([controlErrorsIgnore]), [ngModel]:not([controlErrorsIgnore])', exportAs: 'errorTailor', }) -export class ControlErrorsDirective implements OnInit, OnDestroy { +export class ControlErrorsDirective implements OnInit, OnDestroy, DoCheck { @Input('controlErrors') customErrors: ErrorsMap = {}; @Input() controlErrorsClass: string | string[] | undefined; @Input() controlCustomClass: string | string[] | undefined; @@ -52,6 +54,7 @@ export class ControlErrorsDirective implements OnInit, OnDestroy { @Input() controlErrorsOnBlur: boolean | undefined; @Input() controlErrorsOnChange: boolean | undefined; @Input() controlErrorsOnStatusChange: boolean | undefined; + @Input() controlErrorsOnTouched: boolean | undefined; @Input() controlErrorAnchor: ControlErrorAnchorDirective; private ref: ComponentRef; @@ -62,6 +65,7 @@ export class ControlErrorsDirective implements OnInit, OnDestroy { private mergedConfig: ErrorTailorConfig = {}; private customAnchorDestroyFn: () => void; private host: HTMLElement; + private touchedChanges$ = new Subject(); constructor( private vcr: ViewContainerRef, @@ -91,6 +95,7 @@ export class ControlErrorsDirective implements OnInit, OnDestroy { let changesOnBlur$: Observable = EMPTY; let changesOnChange$: Observable = EMPTY; let changesOnStatusChange$: Observable = EMPTY; + let changesOnTouched$: Observable = EMPTY; if (!this.controlErrorsClass || this.controlErrorsClass?.length === 0) { if (this.mergedConfig.controlErrorsClass && this.mergedConfig.controlErrorsClass) { @@ -118,6 +123,11 @@ export class ControlErrorsDirective implements OnInit, OnDestroy { changesOnStatusChange$ = statusChanges$; } + if (this.mergedConfig.controlErrorsOn.touched) { + // every time the touched property changes, skipping the first emission since it's the initial state + changesOnTouched$ = this.touchedChanges$.asObservable().pipe(distinctUntilChanged(), skip(1)); + } + if (this.isInput && this.mergedConfig.controlErrorsOn.blur) { const blur$ = fromEvent(this.host, 'focusout'); // blurFirstThenUponChange @@ -144,12 +154,30 @@ export class ControlErrorsDirective implements OnInit, OnDestroy { .pipe(takeUntil(this.destroy)) .subscribe(() => { const hasErrors = !!this.control.errors; + if (hasErrors) { this.showError(); } else { this.hideError(); } }); + + changesOnTouched$.pipe(takeUntil(this.destroy)).subscribe(() => { + const hasErrors = !!this.control.errors; + const touched = this.mergedConfig.controlErrorsOn.touched ? this.control.touched : true; + + if (hasErrors && touched) { + this.showError(); + } else { + this.hideError(); + } + }); + } + + ngDoCheck(): void { + if (this.mergedConfig.controlErrorsOn.touched) { + this.touchedChanges$.next(this.control.touched); + } } private setError(text: string, error?: ValidationErrors) { @@ -260,6 +288,7 @@ export class ControlErrorsDirective implements OnInit, OnDestroy { blur: this.controlErrorsOnBlur ?? this.config.controlErrorsOn?.blur ?? true, change: this.controlErrorsOnChange ?? this.config.controlErrorsOn?.change ?? false, status: this.controlErrorsOnStatusChange ?? this.config.controlErrorsOn?.status ?? false, + touched: this.controlErrorsOnTouched ?? this.config.controlErrorsOn?.touched ?? false, }, }; } diff --git a/projects/ngneat/error-tailor/src/lib/error-tailor.providers.ts b/projects/ngneat/error-tailor/src/lib/error-tailor.providers.ts index 8791ada..058a517 100644 --- a/projects/ngneat/error-tailor/src/lib/error-tailor.providers.ts +++ b/projects/ngneat/error-tailor/src/lib/error-tailor.providers.ts @@ -32,6 +32,7 @@ export type ErrorTailorConfig = { blur?: boolean; change?: boolean; status?: boolean; + touched?: boolean; }; }; diff --git a/src/app/app.component.html b/src/app/app.component.html index 709d0a5..b00d8cc 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -1,6 +1,6 @@
- +
@@ -70,6 +70,16 @@

+
+ + Showing/hiding errors on name via touched: + + + +
+

Template

diff --git a/src/app/app.component.ts b/src/app/app.component.ts index a76aa5c..fab8a69 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -6,7 +6,7 @@ import { ControlErrorsDirective } from '@ngneat/error-tailor'; @Component({ selector: 'app-root', templateUrl: './app.component.html', - styleUrls: ['./app.component.scss'] + styleUrls: ['./app.component.scss'], }) export class AppComponent { form: UntypedFormGroup; @@ -14,19 +14,19 @@ export class AppComponent { options = Array.from(Array(5), (_, i) => ({ label: `Animal ${i + 1}`, - id: i + 1 + id: i + 1, })); extraErrors = { minlength: ({ requiredLength }) => `Use country abbreviation! (min ${requiredLength} chars)`, - maxlength: 'Use country abbreviation! (max 3 chars)' + maxlength: 'Use country abbreviation! (max 3 chars)', }; @ViewChild('gdprErrorTailor', { static: true }) gdprErrorTailor: ControlErrorsDirective; formGroup = this.builder.group({ name: [null, Validators.required], - emailAddresses: this.builder.array([this.initEmailAddressFields()]) + emailAddresses: this.builder.array([this.initEmailAddressFields()]), }); get emailAddresses() { @@ -36,7 +36,7 @@ export class AppComponent { initEmailAddressFields(): UntypedFormGroup { return this.builder.group({ label: [null, Validators.required], - emailAddress: [null, [Validators.required, Validators.email]] + emailAddress: [null, [Validators.required, Validators.email]], }); } @@ -62,11 +62,11 @@ export class AppComponent { address: this.builder.group( { city: ['', Validators.required], - country: ['', [Validators.minLength(2), Validators.maxLength(3)]] + country: ['', [Validators.minLength(2), Validators.maxLength(3)]], }, - { validator: addressValidator } + { validator: addressValidator }, ), - gdpr: [false, Validators.requiredTrue] + gdpr: [false, Validators.requiredTrue], }); /** * It's not necessary to set errors directly. It's done via the validator itself. @@ -84,6 +84,14 @@ export class AppComponent { this.gdprErrorTailor.hideError(); } + markNameAsTouched(): void { + this.form.controls.name.markAsTouched(); + } + + markNameAsUntouched(): void { + this.form.controls.name.markAsUntouched(); + } + submit() {} }