From 16a51294e8b3c7f9f4a62e34db43bbafac4016ae Mon Sep 17 00:00:00 2001 From: Francesco Bonomi Date: Mon, 27 May 2024 17:55:14 +0200 Subject: [PATCH 1/9] feat: add touched to config and set the default to false --- projects/ngneat/error-tailor/src/lib/control-error.directive.ts | 1 + projects/ngneat/error-tailor/src/lib/error-tailor.providers.ts | 1 + 2 files changed, 2 insertions(+) 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..5ebb4bb 100644 --- a/projects/ngneat/error-tailor/src/lib/control-error.directive.ts +++ b/projects/ngneat/error-tailor/src/lib/control-error.directive.ts @@ -260,6 +260,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; }; }; From 29867aae8cd525d330409ccd3d27383eec9263e8 Mon Sep 17 00:00:00 2001 From: Francesco Bonomi Date: Tue, 28 May 2024 20:19:27 +0200 Subject: [PATCH 2/9] feat: support showing/hiding errors based on the touched property --- .../src/lib/control-error.directive.ts | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) 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 5ebb4bb..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) { From f487186726c47fe34599363557849322f6ef927f Mon Sep 17 00:00:00 2001 From: Francesco Bonomi Date: Tue, 28 May 2024 22:59:51 +0200 Subject: [PATCH 3/9] feat: update playground to demonstrate controlErrorsOnTouched behavior --- src/app/app.component.html | 12 +++++++++++- src/app/app.component.ts | 24 ++++++++++++++++-------- 2 files changed, 27 insertions(+), 9 deletions(-) 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() {} } From c84ce6cdb64ace57a571a6e8e1fb051cab235e74 Mon Sep 17 00:00:00 2001 From: Francesco Bonomi Date: Tue, 28 May 2024 23:04:10 +0200 Subject: [PATCH 4/9] docs: add controlErrorsOnTouched input and controlErrorsOn.touched --- README.md | 36 +++++++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index cffbec1..4a2ec68 100644 --- a/README.md +++ b/README.md @@ -216,6 +216,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 `form.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 +342,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 From a9aadbf80b8a29a4770436e756d6d9bfcf226700 Mon Sep 17 00:00:00 2001 From: Francesco Bonomi Date: Wed, 29 May 2024 00:41:48 +0200 Subject: [PATCH 5/9] test: test for controlErrorsOnTouched and controlErrorsOn.touched --- .../src/lib/control-error.directive.spec.ts | 85 +++++++++++++------ 1 file changed, 60 insertions(+), 25 deletions(-) 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(); + }); }); }); }); From b446c7d95901baf36b33b3cbcf8b1d77cf145eaf Mon Sep 17 00:00:00 2001 From: Francesco Bonomi Date: Wed, 29 May 2024 00:55:38 +0200 Subject: [PATCH 6/9] docs: formGroup instead of form --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4a2ec68..435cfc3 100644 --- a/README.md +++ b/README.md @@ -216,7 +216,7 @@ 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 `form.markAllAsTouched()`: +- `controlErrorsOnTouched` - To modify the error display behavior to show errors when the control is marked as `touched` via `control.markAsTouched()` or `formGroup.markAllAsTouched()`: ```html From baf5371d729fa8915ebeebc5e121d31a36a23092 Mon Sep 17 00:00:00 2001 From: Francesco Bonomi Date: Wed, 29 May 2024 01:17:49 +0200 Subject: [PATCH 7/9] docs: section to explain how to customize the default behavior --- README.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/README.md b/README.md index 435cfc3..d554b1c 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,36 @@ 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 + +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 will be shown/hidden on every change + touched: true, // errors will be 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 - `controlErrorsClass` - A custom classes that'll be added to the control error component and override custom classes from global config, a component that is added after the form field when an error needs to be displayed: From 9389e606d938efb1ae638740ed8b642dcc0aa999 Mon Sep 17 00:00:00 2001 From: Francesco Bonomi Date: Wed, 29 May 2024 01:28:15 +0200 Subject: [PATCH 8/9] docs: small fixes --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index d554b1c..f4300c3 100644 --- a/README.md +++ b/README.md @@ -107,10 +107,10 @@ 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: @@ -125,8 +125,8 @@ bootstrapApplication(AppComponent, { } }, controlErrorsOn: { - change: true, // errors will be shown/hidden on every change - touched: true, // errors will be shown/hidden when the controls are marked as touched + change: true, // errors are shown/hidden on every change + touched: true, // errors are shown/hidden when the controls are marked as touched } }) ] From 2d6440d7ff52eeaa45ee7c8e5327d1b1b9f34eee Mon Sep 17 00:00:00 2001 From: Francesco Bonomi Date: Wed, 29 May 2024 01:50:08 +0200 Subject: [PATCH 9/9] chore: clean diff