Skip to content

Commit

Permalink
feat(config): Support for custom error component & positioning (#15)
Browse files Browse the repository at this point in the history
  • Loading branch information
harikvpy committed Jul 10, 2020
1 parent 5426a54 commit c9d7776
Show file tree
Hide file tree
Showing 12 changed files with 335 additions and 36 deletions.
9 changes: 9 additions & 0 deletions .all-contributorsrc
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,15 @@
"contributions": [
"code"
]
},
{
"login": "harikvpy",
"name": "Hari Mahadevan",
"avatar_url": "https://avatars3.githubusercontent.com/u/6202401?v=4",
"profile": "http:https://www.smallpearl.com",
"contributions": [
"code"
]
}
],
"contributorsPerLine": 7
Expand Down
94 changes: 92 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,96 @@ The library adds a `form-submitted` to the submitted form. You can use it to sty
}
}
```
- `controlErrorComponent` - Optional. Allows changing the default component that is used to render
the errors. This component should implement the `ControlErrorComponent` interface. If you only need to
replace the error component's template, you may derive it from the default component,
`DefaultControlErrorComponent`, and provide the requisite HTML template.

A common example is when using Ionic forms where each form field is wrapped in an `ion-item` and errors
are best displayed as a sibling `ion-item` of the field. Example below shows how this can be done using
a custom control error component.

For example:
```ts
// Custom error component that will replace the standard DefaultControlErrorComponent.
@Component({
template: `
<ion-item lines="none" class="ion-text-wrap" [class.hide-control]="hideError">
<ion-label color="danger" class="ion-no-margin ion-text-wrap" stacked>
{{ errorText }}
</ion-label>
</ion-item>
`
})
export class IonicControlErrorComponent extends DefaultControlErrorComponent {
}

@NgModule({
declarations: [AppComponent, IonicControlErrorComponent],
imports: [
ReactiveFormsModule,
ErrorTailorModule.forRoot({
errors: {
useValue: {
required: 'This field is required'
}
},
controlErrorComponent: IonicControlErrorComponent
})
],
bootstrap: [AppComponent]
})
export class AppModule {}
```
- `controlErrorComponentAnchorFn` - Optional. A hook function that allows the error component's
HTML element to be repositioned in the DOM. By default error components are inserted at the
bottom of the field with error. If your UI layout dictates a different positioning
scheme, you may use this hook.

Since this error element can be placed anywhere in the DOM, it also has to be
removed when the error component is destroyed. To provide for this, this
function should return a callback that will then be invoked when the error component
is destroyed. You may use this to remove the error HTML element that you inserted
into the DOM yourself.

Example below shows how the Ionic specific error component is repositioned in the DOM
to suit Ionic's form layout. `hostElem` is the HTML element for the form control and
`errorElem` is the HTML element for the error component.
```ts
anchorIonicErrorComponent(hostElem: Element, errorElem: Element) {
hostElement.parentElement.insertAdjacentElement('afterend', errorElement);
return () => {
let errorNode = hostElement.parentElement.querySelector('custom-control-error');
if (errorNode) {
errorNode.remove();
}
};
}

@NgModule({
declarations: [AppComponent, IonicControlErrorComponent],
imports: [
ReactiveFormsModule,
ErrorTailorModule.forRoot({
errors: {
useValue: {
required: 'This field is required'
}
},
controlErrorComponent: IonicControlErrorComponent,
controlErrorComponentAnchorFn: anchorIonicErrorComponent
})
],
bootstrap: [AppComponent]
})
export class AppModule {}
```

- `controlErrorsOnBlur` - To modify the error display behavior and show the errors on submission alone, set the following input:

```html
<input [controlErrorsOnBlur]="false" formControlName="name" />
```

## Recipes

Expand Down Expand Up @@ -274,14 +364,14 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<td align="center"><a href="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/tonivj5"><img src="https://avatars2.githubusercontent.com/u/7110786?v=4" width="100px;" alt=""/><br /><sub><b>Toni Villena</b></sub></a><br /><a href="https://github.com/@ngneat/error-tailor/commits?author=tonivj5" title="Code">💻</a> <a href="https://github.com/@ngneat/error-tailor/commits?author=tonivj5" title="Tests">⚠️</a></td>
<td align="center"><a href="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/theblushingcrow"><img src="https://avatars3.githubusercontent.com/u/638818?v=4" width="100px;" alt=""/><br /><sub><b>Inbal Sinai</b></sub></a><br /><a href="https://github.com/@ngneat/error-tailor/commits?author=theblushingcrow" title="Documentation">📖</a></td>
<td align="center"><a href="https://twitter.com/dmorosinotto"><img src="https://avatars2.githubusercontent.com/u/3982050?v=4" width="100px;" alt=""/><br /><sub><b>Daniele Morosinotto</b></sub></a><br /><a href="https://github.com/@ngneat/error-tailor/commits?author=dmorosinotto" title="Code">💻</a> <a href="https://github.com/@ngneat/error-tailor/commits?author=dmorosinotto" title="Documentation">📖</a> <a href="#example-dmorosinotto" title="Examples">💡</a></td>
<td align="center"><a href="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/rhutchison"><img src="https://avatars3.githubusercontent.com/u/1460261?v=4" width="100px;" alt=""/><br /><sub><b>Ryan Hutchison</b></sub></a><br /><a href="https://github.com/@ngneat/error-tailor/issues?q=author%3Arhutchison" title="Bug reports">🐛</a> <a href="https://github.com/@ngneat/error-tailor/commits?author=rhutchison" title="Code">💻</a> <a href="https://github.com/@ngneat/error-tailor/commits?author=rhutchison" title="Tests">⚠️</a></td>
<td align="center"><a href="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/rhutchison"><img src="https://avatars3.githubusercontent.com/u/1460261?v=4" width="100px;" alt=""/><br /><sub><b>Ryan Hutchison</b></sub></a><br /><a href="https://github.com/@ngneat/error-tailor/issues?q=author%3Arhutchison" title="Bug reports">🐛</a> <a href="https://github.com/@ngneat/error-tailor/commits?author=rhutchison" title="Documentation">📖</a> <a href="https://github.com/@ngneat/error-tailor/commits?author=rhutchison" title="Code">💻</a> <a href="https://github.com/@ngneat/error-tailor/commits?author=rhutchison" title="Tests">⚠️</a></td>
<td align="center"><a href="http:https://www.mlc.cz"><img src="https://avatars3.githubusercontent.com/u/5693835?v=4" width="100px;" alt=""/><br /><sub><b>Miloš Lapiš</b></sub></a><br /><a href="https://github.com/@ngneat/error-tailor/commits?author=mlc-mlapis" title="Code">💻</a></td>
<td align="center"><a href="http:https://www.smallpearl.com"><img src="https://avatars3.githubusercontent.com/u/6202401?v=4" width="100px;" alt=""/><br /><sub><b>Hari Mahadevan</b></sub></a><br /><a href="https://github.com/@ngneat/error-tailor/commits?author=harikvpy" title="Code">💻</a></td>
</tr>
</table>

<!-- markdownlint-enable -->
<!-- prettier-ignore-end -->

<!-- ALL-CONTRIBUTORS-LIST:END -->

This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!
Expand Down
6 changes: 5 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { Spectator, createComponentFactory, byText } from '@ngneat/spectator';

import { ControlErrorComponent } from './control-error.component';
import { DefaultControlErrorComponent } from './control-error.component';

describe('ControlErrorComponent', () => {
let spectator: Spectator<ControlErrorComponent>;
const createComponent = createComponentFactory(ControlErrorComponent);
let spectator: Spectator<DefaultControlErrorComponent>;
const createComponent = createComponentFactory(DefaultControlErrorComponent);

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

Expand All @@ -18,8 +18,8 @@ describe('ControlErrorComponent', () => {

spectator.detectChanges();

expect(spectator.component._text).toBe('test');
expect(spectator.component.hide).toBeFalse();
expect(spectator.component.errorText).toBe('test');
expect(spectator.component.hideError).toBeFalse();
expect(spectator.query(byText('test'))).toBeTruthy();
});

Expand All @@ -32,8 +32,8 @@ describe('ControlErrorComponent', () => {

spectator.detectChanges();

expect(spectator.component._text).toBe('');
expect(spectator.component.hide).toBeTrue();
expect(spectator.component.errorText).toBe('');
expect(spectator.component.hideError).toBeTrue();
expect(spectator.query(byText('test'))).toBeNull();
});

Expand Down Expand Up @@ -67,11 +67,11 @@ describe('ControlErrorComponent', () => {
const { component } = spectator;
component.createTemplate('fakeTemplate' as any, { testError: 'test' }, 'test error');

expect(component.context).toEqual({
expect(component.errorContext).toEqual({
$implicit: { testError: 'test' },
text: 'test error'
});

expect(component._tpl).toBe('fakeTemplate' as any);
expect(component.errorTemplate).toBe('fakeTemplate' as any);
});
});
36 changes: 22 additions & 14 deletions projects/ngneat/error-tailor/src/lib/control-error.component.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, TemplateRef } from '@angular/core';
import { ValidationErrors } from '@angular/forms';

export type ErrorComponentTemplate = TemplateRef<{ $implicit: ValidationErrors; text: string }>;

export interface ControlErrorComponent {
customClass: string;
text: string | null;
createTemplate?(tpl: ErrorComponentTemplate, error: ValidationErrors, text: string): void;
}

@Component({
selector: 'control-error',
template: `
<label class="control-error" [class.hide-control]="hide" *ngIf="!_tpl">{{ _text }}</label>
<ng-template *ngTemplateOutlet="_tpl; context: context"></ng-template>
<label class="control-error" [class.hide-control]="hideError" *ngIf="!errorTemplate">{{ errorText }}</label>
<ng-template *ngTemplateOutlet="errorTemplate; context: errorContext"></ng-template>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [
Expand All @@ -20,15 +28,15 @@ import { ValidationErrors } from '@angular/forms';
`
]
})
export class ControlErrorComponent {
_text: string | null = null;
_tpl: TemplateRef<{ $implicit: ValidationErrors; text: string }> | undefined;
context: { $implicit: ValidationErrors; text: string };
hide = true;

createTemplate(tpl: TemplateRef<any>, error: ValidationErrors, text: string) {
this._tpl = tpl;
this.context = { $implicit: error, text };
export class DefaultControlErrorComponent implements ControlErrorComponent {
errorText: string | null = null;
errorTemplate: ErrorComponentTemplate | undefined;
errorContext: { $implicit: ValidationErrors; text: string };
hideError = true;

createTemplate(tpl: ErrorComponentTemplate, error: ValidationErrors, text: string) {
this.errorTemplate = tpl;
this.errorContext = { $implicit: error, text };
this.cdr.markForCheck();
}

Expand All @@ -37,9 +45,9 @@ export class ControlErrorComponent {
}

set text(value: string | null) {
if (value !== this._text) {
this._text = value;
this.hide = !value;
if (value !== this.errorText) {
this.errorText = value;
this.hideError = !value;
this.cdr.markForCheck();
}
}
Expand Down
111 changes: 111 additions & 0 deletions projects/ngneat/error-tailor/src/lib/control-error.directive.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import { byPlaceholder, byText, createComponentFactory, Spectator } from '@ngneat/spectator';
import { ErrorTailorModule } from '@ngneat/error-tailor';
import { tick, fakeAsync } from '@angular/core/testing';
import { DefaultControlErrorComponent } from './control-error.component';
import { Observable, asyncScheduler, scheduled } from 'rxjs';
import { map } from 'rxjs/operators';

Expand Down Expand Up @@ -372,4 +373,114 @@ describe('ControlErrorDirective', () => {
});
});
});

describe('GlobalConfig', () => {
@Component({
selector: 'custom-error-form-group',
template: `
<form [formGroup]="form" errorTailor>
<input formControlName="name" placeholder="Name" *ngIf="showName" />
</form>
`
})
class CustomErrorFormGroupComponent {
form = this.builder.group({
name: new FormControl('', [Validators.required])
});
showName = true;
constructor(private builder: FormBuilder) {}
}

@Component({
selector: 'custom-error-component',
template: `
<h1>{{ errorText }}</h1>
`
})
class CustomControlErrorComponent extends DefaultControlErrorComponent {}

function getCustomErrorComponentFactory<C>(
component: Type<C>,
controlErrorComponentAnchorFn: (hostElem: Element, errorElem: Element) => () => void = null
) {
return createComponentFactory({
component,
declarations: [CustomControlErrorComponent],
imports: [
FormsModule,
ReactiveFormsModule,
ErrorTailorModule.forRoot({
errors: {
useValue: {
required: () => 'required error'
}
},
controlErrorComponent: CustomControlErrorComponent,
controlErrorComponentAnchorFn: controlErrorComponentAnchorFn
})
]
});
}

describe('CustomControlErrorComponent', () => {
let spectator: Spectator<CustomErrorFormGroupComponent>;
const createComponent = getCustomErrorComponentFactory(CustomErrorFormGroupComponent);

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

it('should create custom error component', () => {
const input = spectator.query<HTMLInputElement>(byPlaceholder('Name'));

typeInElementAndFocusOut(spectator, '', input);

expect(spectator.query('h1')).toBeTruthy();
expect(spectator.query(byText('required error'))).toBeTruthy();
});
});

describe('ErrorComponentAnchorFnCallback', () => {
let anchorFnCalled = false;
let anchorFnDestroyCalled = false;

let spectator: Spectator<CustomErrorFormGroupComponent>;
const createComponent = getCustomErrorComponentFactory(
CustomErrorFormGroupComponent,
(hostElem: Element, errorElem: Element) => {
anchorFnCalled = true;
expect(hostElem).toBeTruthy();
expect(errorElem).toBeTruthy();
return () => {
anchorFnDestroyCalled = true;
};
}
);

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

it('should call error component anchor fn', () => {
const input = spectator.query<HTMLInputElement>(byPlaceholder('Name'));

typeInElementAndFocusOut(spectator, '', input);

expect(anchorFnCalled).toBeTruthy();
});

it('should call error component anchor fn destroy callback', () => {
anchorFnCalled = false; // reset values, just to be safe
anchorFnDestroyCalled = false;

const input = spectator.query<HTMLInputElement>(byPlaceholder('Name'));
typeInElementAndFocusOut(spectator, '', input);
expect(anchorFnCalled).toBeTruthy();

// This will remove the name input field, which should also remove the
// custom control error component created earlier. And removal of the
// custom control error component, should result in a call to the anchor function's
// destroy callback.
spectator.component.showName = false;
spectator.detectChanges();
expect(anchorFnDestroyCalled).toBeTruthy();
});
});
});
});
Loading

0 comments on commit c9d7776

Please sign in to comment.