Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support showing errors when a control is marked as touched #114

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
68 changes: 58 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<input [controlErrorsOnBlur]="false" [controlErrorsOnAsync]="false" formControlName="name" />
```

## Inputs

Expand Down Expand Up @@ -216,6 +246,12 @@ One typical case when to use it is radio buttons in the same radio group where i
<input [controlErrorsOnChange]="true" formControlName="name" />
```

- `controlErrorsOnTouched` - To modify the error display behavior to show errors when the control is marked as `touched` via `control.markAsTouched()` or `formGroup.markAllAsTouched()`:

```html
<input [controlErrorsOnTouched]="true" formControlName="name" />
```

## 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()`.
Expand Down Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -64,6 +69,7 @@ describe('ControlErrorDirective', () => {
<input formControlName="username" placeholder="Username" />
<input formControlName="onSubmitOnly" placeholder="On submit only" [controlErrorsOnBlur]="false" />
<input formControlName="onEveryChange" placeholder="On every change" [controlErrorsOnChange]="true" />
<input formControlName="onTouchedChange" placeholder="On touched change" [controlErrorsOnTouched]="true" />
<button type="submit">Submit</button>
</form>
`,
Expand All @@ -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;
Expand Down Expand Up @@ -137,8 +144,10 @@ describe('ControlErrorDirective', () => {

const onSubmitOnly = spectator.query<HTMLInputElement>(byPlaceholder('On submit only'));
const onEveryChange = spectator.query<HTMLInputElement>(byPlaceholder('On every change'));
const onTouchedChange = spectator.query<HTMLInputElement>(byPlaceholder('On touched change'));
typeInElementAndFocusOut(spectator, 'test', onSubmitOnly);
typeInElementAndFocusOut(spectator, 'test', onEveryChange);
typeInElementAndFocusOut(spectator, 'test', onTouchedChange);

spectator.click('button');

Expand Down Expand Up @@ -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<HTMLInputElement>(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<HTMLInputElement>(byPlaceholder('Ignored'));

Expand Down Expand Up @@ -500,10 +519,7 @@ describe('ControlErrorDirective', () => {
})
class CustomControlErrorComponent extends DefaultControlErrorComponent {}

function getCustomErrorComponentFactory<C>(
component: Type<C>,
controlErrorComponentAnchorFn: (hostElem: Element, errorElem: Element) => () => void = null,
) {
function getCustomErrorComponentFactory<C>(component: Type<C>, config: Partial<ErrorTailorConfig> = {}) {
return createComponentFactory({
component,
providers: [
Expand All @@ -516,10 +532,7 @@ describe('ControlErrorDirective', () => {
controlErrorsClass: ['global', 'config'],
controlCustomClass: 'control custom',
controlErrorComponent: CustomControlErrorComponent,
controlErrorComponentAnchorFn,
controlErrorsOn: {
change: true,
},
...config,
}),
],
imports: [FormsModule, CustomControlErrorComponent, ReactiveFormsModule, errorTailorImports],
Expand Down Expand Up @@ -563,17 +576,16 @@ describe('ControlErrorDirective', () => {
let anchorFnDestroyCalled = false;

let spectator: Spectator<CustomErrorFormGroupComponent>;
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();
return () => {
anchorFnDestroyCalled = true;
};
},
);
});

beforeEach(() => (spectator = createComponent()));

Expand Down Expand Up @@ -604,24 +616,47 @@ describe('ControlErrorDirective', () => {
});

describe('controlErrorsOn', () => {
let spectator: Spectator<CustomErrorFormGroupComponent>;
const createComponent = getCustomErrorComponentFactory(CustomErrorFormGroupComponent);
describe('change', () => {
let spectator: Spectator<CustomErrorFormGroupComponent>;
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<HTMLInputElement>(byPlaceholder('Name'));
it('should show errors on change when overriding default behavior by setting controlErrorsOn.change to true', () => {
const input = spectator.query<HTMLInputElement>(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<CustomErrorFormGroupComponent>;
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<HTMLInputElement>(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();
});
});
});
});
Expand Down
31 changes: 30 additions & 1 deletion projects/ngneat/error-tailor/src/lib/control-error.directive.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
ComponentRef,
Directive,
DoCheck,
ElementRef,
EmbeddedViewRef,
Inject,
Expand All @@ -27,6 +28,7 @@ import {
Observable,
Subject,
tap,
skip,
} from 'rxjs';

import { ControlErrorAnchorDirective } from './control-error-anchor.directive';
Expand All @@ -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 = {};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do u think about converting the inputs to signal inputs?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be nice indeed! How about doing it in a separate PR, though? I believe we would also need to update the Angular version. input seems to be exported from v17.2.0 on, unless I'm missing something.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure

@Input() controlErrorsClass: string | string[] | undefined;
@Input() controlCustomClass: string | string[] | undefined;
Expand All @@ -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<ControlErrorComponent>;
Expand All @@ -62,6 +65,7 @@ export class ControlErrorsDirective implements OnInit, OnDestroy {
private mergedConfig: ErrorTailorConfig = {};
private customAnchorDestroyFn: () => void;
private host: HTMLElement;
private touchedChanges$ = new Subject<boolean>();

constructor(
private vcr: ViewContainerRef,
Expand Down Expand Up @@ -91,6 +95,7 @@ export class ControlErrorsDirective implements OnInit, OnDestroy {
let changesOnBlur$: Observable<any> = EMPTY;
let changesOnChange$: Observable<any> = EMPTY;
let changesOnStatusChange$: Observable<any> = EMPTY;
let changesOnTouched$: Observable<boolean> = EMPTY;

if (!this.controlErrorsClass || this.controlErrorsClass?.length === 0) {
if (this.mergedConfig.controlErrorsClass && this.mergedConfig.controlErrorsClass) {
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's use afterRender hook and only invoke it when this.mergedConfig.controlErrorsOn.touched

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about monkey patch ngControl methods instead?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I considered monkey patching since it would be the most performant solution (called the least amount of times), but I think it's not the safest solution.

First of all we'd need to monkey patch all methods that might change the touched property, so control.markAsTouched(), control.markAsUntouched(), formGroup.markAllAsTouched(). We also could miss methods introduced in new versions of Angular that change that property, for example formGroup.markAllAsTouched() was introduced in v8.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, I can look into the afterRender solution. Do you think it would be more performant?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, because it'll only do it if this.mergedConfig.controlErrorsOn.touched is true

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, going to call detectChanges in the changesOnTouched$ which has distinctUntilChanged, so will only run when it actually changes.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mmm, I'm a bit puzzled. I'm pretty sure I successfully tested the afterRender solution earlier, but now when trying to implement it again I get this error if I call change detection:

Error: NG0102: A new render operation began before the previous operation ended. Did you trigger change detection from afterRender or afterNextRender?

which didn't show up before… Seems a timing issue, because if I call detectChanges() in a setTimeout with just 10ms of delay the error doesn't throw and it works fine.

I created a separate branch with the change, care to have a look?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm very busy at work currently. Try to comment some code and see where is the issue (You can maybe begin with setError)

Copy link
Author

@frabonomi frabonomi Aug 6, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@NetanelBasal Does it even make sense to use afterRender? Something that will need for sure a render to be shown, probably doesn't make sense to be in afterRender.

Furthermore, the documentation says that

The execution of render callbacks are not tied to any specific component instance, but instead an application-wide hook.

so the callback would be run after every render application-wide.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so the callback would be run after every render application-wide.

Correct. That's one of the main issues with this approach. I think we can go with MutationObserver or monkey patching

if (this.mergedConfig.controlErrorsOn.touched) {
this.touchedChanges$.next(this.control.touched);
}
}

private setError(text: string, error?: ValidationErrors) {
Expand Down Expand Up @@ -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,
},
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export type ErrorTailorConfig = {
blur?: boolean;
change?: boolean;
status?: boolean;
touched?: boolean;
};
};

Expand Down
12 changes: 11 additions & 1 deletion src/app/app.component.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<form [formGroup]="form" errorTailor>
<div class="form-group">
<input class="form-control" formControlName="name" placeholder="Name" />
<input class="form-control" formControlName="name" placeholder="Name" [controlErrorsOnTouched]="true" />
</div>
<div class="form-check form-group">
<input class="form-check-input" type="checkbox" formControlName="terms" id="check" [controlErrorAnchor]="anchor" />
Expand Down Expand Up @@ -70,6 +70,16 @@
</span>
</div>
<br />
<div class="text-right">
<span>
Showing/hiding errors on name via touched:
<button type="button" class="btn btn-success btn-custom" (click)="markNameAsTouched()">Mark as touched</button>
<button type="button" class="btn btn-success btn-custom" (click)="markNameAsUntouched()">
Mark as untouched
</button>
</span>
</div>
<br />
<div class="text-right"><button class="btn btn-success btn-custom">Submit</button> <br /></div>
</form>
<h1>Template</h1>
Expand Down
Loading